feat(email-templates): group Templates UI by category with core sub-sections
This commit is contained in:
@@ -2235,7 +2235,14 @@
|
|||||||
"billing": "Rechnungen"
|
"billing": "Rechnungen"
|
||||||
},
|
},
|
||||||
"featureOff": "Funktion aus",
|
"featureOff": "Funktion aus",
|
||||||
"featureOffTooltip": "Die zugehörige Funktion ist derzeit deaktiviert. Du kannst die Vorlage trotzdem bearbeiten — sie wird verwendet, sobald die Funktion wieder aktiviert ist."
|
"featureOffTooltip": "Die zugehörige Funktion ist derzeit deaktiviert. Du kannst die Vorlage trotzdem bearbeiten — sie wird verwendet, sobald die Funktion wieder aktiviert ist.",
|
||||||
|
"subcategories": {
|
||||||
|
"gallery": "Galerien",
|
||||||
|
"admin": "Admin-Konten",
|
||||||
|
"backup": "Backup & Wiederherstellung",
|
||||||
|
"system": "System-Updates",
|
||||||
|
"other": "Sonstige"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cms": {
|
"cms": {
|
||||||
"title": "CMS-Seiten",
|
"title": "CMS-Seiten",
|
||||||
|
|||||||
@@ -1889,7 +1889,14 @@
|
|||||||
"billing": "Billing"
|
"billing": "Billing"
|
||||||
},
|
},
|
||||||
"featureOff": "Feature off",
|
"featureOff": "Feature off",
|
||||||
"featureOffTooltip": "The feature this template belongs to is currently disabled. You can still edit the template — it will be used once the feature is re-enabled."
|
"featureOffTooltip": "The feature this template belongs to is currently disabled. You can still edit the template — it will be used once the feature is re-enabled.",
|
||||||
|
"subcategories": {
|
||||||
|
"gallery": "Galleries",
|
||||||
|
"admin": "Admin accounts",
|
||||||
|
"backup": "Backup & restore",
|
||||||
|
"system": "System updates",
|
||||||
|
"other": "Other"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cms": {
|
"cms": {
|
||||||
"title": "CMS Pages",
|
"title": "CMS Pages",
|
||||||
|
|||||||
@@ -1835,7 +1835,14 @@
|
|||||||
"billing": "Facturation"
|
"billing": "Facturation"
|
||||||
},
|
},
|
||||||
"featureOff": "Fonction désactivée",
|
"featureOff": "Fonction désactivée",
|
||||||
"featureOffTooltip": "La fonctionnalité à laquelle appartient ce modèle est actuellement désactivée. Vous pouvez toujours modifier le modèle — il sera utilisé une fois la fonctionnalité réactivée."
|
"featureOffTooltip": "La fonctionnalité à laquelle appartient ce modèle est actuellement désactivée. Vous pouvez toujours modifier le modèle — il sera utilisé une fois la fonctionnalité réactivée.",
|
||||||
|
"subcategories": {
|
||||||
|
"gallery": "Galeries",
|
||||||
|
"admin": "Comptes administrateurs",
|
||||||
|
"backup": "Sauvegarde & restauration",
|
||||||
|
"system": "Mises à jour système",
|
||||||
|
"other": "Autres"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cms": {
|
"cms": {
|
||||||
"title": "Pages CMS",
|
"title": "Pages CMS",
|
||||||
|
|||||||
@@ -1889,7 +1889,14 @@
|
|||||||
"billing": "Facturatie"
|
"billing": "Facturatie"
|
||||||
},
|
},
|
||||||
"featureOff": "Functie uit",
|
"featureOff": "Functie uit",
|
||||||
"featureOffTooltip": "De functie waar deze sjabloon bij hoort, is momenteel uitgeschakeld. U kunt de sjabloon nog steeds bewerken — deze wordt gebruikt zodra de functie weer is ingeschakeld."
|
"featureOffTooltip": "De functie waar deze sjabloon bij hoort, is momenteel uitgeschakeld. U kunt de sjabloon nog steeds bewerken — deze wordt gebruikt zodra de functie weer is ingeschakeld.",
|
||||||
|
"subcategories": {
|
||||||
|
"gallery": "Galerijen",
|
||||||
|
"admin": "Adminaccounts",
|
||||||
|
"backup": "Back-up & herstel",
|
||||||
|
"system": "Systeemupdates",
|
||||||
|
"other": "Overige"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cms": {
|
"cms": {
|
||||||
"title": "CMS-pagina's",
|
"title": "CMS-pagina's",
|
||||||
|
|||||||
@@ -1914,7 +1914,14 @@
|
|||||||
"billing": "Faturamento"
|
"billing": "Faturamento"
|
||||||
},
|
},
|
||||||
"featureOff": "Recurso desativado",
|
"featureOff": "Recurso desativado",
|
||||||
"featureOffTooltip": "O recurso ao qual este modelo pertence está atualmente desativado. Você ainda pode editar o modelo — ele será usado assim que o recurso for reativado."
|
"featureOffTooltip": "O recurso ao qual este modelo pertence está atualmente desativado. Você ainda pode editar o modelo — ele será usado assim que o recurso for reativado.",
|
||||||
|
"subcategories": {
|
||||||
|
"gallery": "Galerias",
|
||||||
|
"admin": "Contas de administrador",
|
||||||
|
"backup": "Backup & restauração",
|
||||||
|
"system": "Atualizações do sistema",
|
||||||
|
"other": "Outros"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cms": {
|
"cms": {
|
||||||
"title": "Páginas CMS",
|
"title": "Páginas CMS",
|
||||||
|
|||||||
@@ -1939,7 +1939,14 @@
|
|||||||
"billing": "Счета"
|
"billing": "Счета"
|
||||||
},
|
},
|
||||||
"featureOff": "Функция выключена",
|
"featureOff": "Функция выключена",
|
||||||
"featureOffTooltip": "Связанная функция в данный момент отключена. Вы по-прежнему можете редактировать шаблон — он будет использоваться после повторного включения функции."
|
"featureOffTooltip": "Связанная функция в данный момент отключена. Вы по-прежнему можете редактировать шаблон — он будет использоваться после повторного включения функции.",
|
||||||
|
"subcategories": {
|
||||||
|
"gallery": "Галереи",
|
||||||
|
"admin": "Учётные записи администраторов",
|
||||||
|
"backup": "Резервное копирование и восстановление",
|
||||||
|
"system": "Системные обновления",
|
||||||
|
"other": "Прочее"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"cms": {
|
"cms": {
|
||||||
"title": "Страницы CMS",
|
"title": "Страницы CMS",
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.ts
|
|||||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Template categorisation (migration 098). Order matters — sidebar
|
* Template categorisation (migration 098). Sidebar sections render
|
||||||
* sections render in this sequence. Empty categories are hidden
|
* in this order. Empty categories are hidden automatically. New
|
||||||
* automatically. New categories: add the key here, give it an i18n
|
* categories: add the key here, give it an i18n label
|
||||||
* label below, set `feature_flag` on templates that should chip
|
* (email.categories.<key>), set `feature_flag` on templates that
|
||||||
* out when the matching flag is off. No other UI changes required.
|
* should chip out when the matching flag is off. No other UI
|
||||||
|
* changes required.
|
||||||
*/
|
*/
|
||||||
const CATEGORY_ORDER: readonly string[] = [
|
const CATEGORY_ORDER: readonly string[] = [
|
||||||
'core',
|
'core',
|
||||||
@@ -41,6 +42,20 @@ const CATEGORY_ORDER: readonly string[] = [
|
|||||||
'billing',
|
'billing',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sub-categorisation inside `core` (which carries 14 templates and
|
||||||
|
* deserves its own internal headers). Order is the render sequence.
|
||||||
|
* Templates whose subcategory isn't in this list fall through to a
|
||||||
|
* trailing "other" bucket so a forward-compat row never disappears.
|
||||||
|
* Other top-level categories are flat (no sub-sections) for now.
|
||||||
|
*/
|
||||||
|
const CORE_SUBCATEGORY_ORDER: readonly string[] = [
|
||||||
|
'gallery',
|
||||||
|
'admin',
|
||||||
|
'backup',
|
||||||
|
'system',
|
||||||
|
] as const;
|
||||||
|
|
||||||
const defaultTemplateKeys = [
|
const defaultTemplateKeys = [
|
||||||
{
|
{
|
||||||
key: 'gallery_created',
|
key: 'gallery_created',
|
||||||
@@ -741,70 +756,113 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
editable, just chip-tagged so the admin knows the
|
editable, just chip-tagged so the admin knows the
|
||||||
feature is dormant. */}
|
feature is dormant. */}
|
||||||
{(() => {
|
{(() => {
|
||||||
const grouped: Record<string, EmailTemplate[]> = {};
|
// 1. Bucket templates by top-level category (forward-compat:
|
||||||
|
// unknown categories fall into 'core').
|
||||||
|
const byCategory: Record<string, EmailTemplate[]> = {};
|
||||||
for (const template of templates) {
|
for (const template of templates) {
|
||||||
const cat = CATEGORY_ORDER.includes(template.category || 'core')
|
const cat = CATEGORY_ORDER.includes(template.category || 'core')
|
||||||
? (template.category || 'core')
|
? (template.category || 'core')
|
||||||
: 'core';
|
: 'core';
|
||||||
(grouped[cat] = grouped[cat] || []).push(template);
|
(byCategory[cat] = byCategory[cat] || []).push(template);
|
||||||
}
|
}
|
||||||
const visibleSections = CATEGORY_ORDER.filter((c) => grouped[c]?.length);
|
const visibleCategories = CATEGORY_ORDER.filter((c) => byCategory[c]?.length);
|
||||||
|
|
||||||
|
// 2. Renders a single template button. Pulled out so the
|
||||||
|
// flat path and the sub-category path share it.
|
||||||
|
const renderTemplate = (template: EmailTemplate) => {
|
||||||
|
const templateInfo = defaultTemplateKeys.find((t) => t.key === template.template_key);
|
||||||
|
const translationCount = getTranslationCount(template);
|
||||||
|
const enTranslation = template.translations?.en;
|
||||||
|
const featureOff = template.feature_flag
|
||||||
|
? featureFlags[template.feature_flag as FeatureKey] === false
|
||||||
|
: false;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={template.template_key}
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedTemplateKey(template.template_key);
|
||||||
|
setEditedTemplate(template);
|
||||||
|
}}
|
||||||
|
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
||||||
|
selectedTemplateKey === template.template_key
|
||||||
|
? 'tile-selected'
|
||||||
|
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">
|
||||||
|
{templateInfo?.name || template.template_key}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
|
{featureOff && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
|
||||||
|
title={t('email.featureOffTooltip', 'The feature this template belongs to is currently disabled. You can still edit the template — it will be used once the feature is re-enabled.')}
|
||||||
|
>
|
||||||
|
{t('email.featureOff', 'Feature off')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-200 dark:bg-neutral-600 text-neutral-600 dark:text-neutral-300">
|
||||||
|
{translationCount}/{SUPPORTED_LANGUAGES.length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
|
||||||
|
{enTranslation?.subject || ''}
|
||||||
|
</p>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
{visibleSections.map((category) => (
|
{visibleCategories.map((category) => {
|
||||||
<div key={category}>
|
// Inside 'core' we group templates further by
|
||||||
<h4 className="px-1 mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
// subcategory so the busy bucket reads cleanly.
|
||||||
{t(`email.categories.${category}`, category)}
|
// Other categories render their templates flat.
|
||||||
</h4>
|
if (category === 'core') {
|
||||||
<div className="space-y-2">
|
const bySub: Record<string, EmailTemplate[]> = {};
|
||||||
{grouped[category].map((template) => {
|
for (const template of byCategory.core) {
|
||||||
const templateInfo = defaultTemplateKeys.find((t) => t.key === template.template_key);
|
const sub = CORE_SUBCATEGORY_ORDER.includes(template.subcategory || '')
|
||||||
const translationCount = getTranslationCount(template);
|
? (template.subcategory as string)
|
||||||
const enTranslation = template.translations?.en;
|
: 'other';
|
||||||
const featureOff = template.feature_flag
|
(bySub[sub] = bySub[sub] || []).push(template);
|
||||||
? featureFlags[template.feature_flag as FeatureKey] === false
|
}
|
||||||
: false;
|
const visibleSubs = [
|
||||||
return (
|
...CORE_SUBCATEGORY_ORDER.filter((s) => bySub[s]?.length),
|
||||||
<button
|
...(bySub.other?.length ? ['other'] : []),
|
||||||
key={template.template_key}
|
];
|
||||||
onClick={() => {
|
return (
|
||||||
setSelectedTemplateKey(template.template_key);
|
<div key={category}>
|
||||||
setEditedTemplate(template);
|
<h4 className="px-1 mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||||
}}
|
{t(`email.categories.${category}`, category)}
|
||||||
className={`w-full text-left p-3 rounded-lg transition-colors ${
|
</h4>
|
||||||
selectedTemplateKey === template.template_key
|
<div className="space-y-4 pl-1">
|
||||||
? 'tile-selected'
|
{visibleSubs.map((sub) => (
|
||||||
: 'bg-neutral-50 dark:bg-neutral-700 border-2 border-transparent hover:bg-neutral-100 dark:hover:bg-neutral-600'
|
<div key={sub}>
|
||||||
}`}
|
<h5 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
|
||||||
>
|
{t(`email.subcategories.${sub}`, sub)}
|
||||||
<div className="flex items-center justify-between gap-2">
|
</h5>
|
||||||
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">
|
<div className="space-y-2">
|
||||||
{templateInfo?.name || template.template_key}
|
{bySub[sub].map(renderTemplate)}
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-1.5 flex-shrink-0">
|
|
||||||
{featureOff && (
|
|
||||||
<span
|
|
||||||
className="text-[10px] uppercase tracking-wider px-1.5 py-0.5 rounded font-semibold bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-200"
|
|
||||||
title={t('email.featureOffTooltip', 'The feature this template belongs to is currently disabled. You can still edit the template — it will be used once the feature is re-enabled.')}
|
|
||||||
>
|
|
||||||
{t('email.featureOff', 'Feature off')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-xs px-2 py-0.5 rounded-full bg-neutral-200 dark:bg-neutral-600 text-neutral-600 dark:text-neutral-300">
|
|
||||||
{translationCount}/{SUPPORTED_LANGUAGES.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
|
))}
|
||||||
{enTranslation?.subject || ''}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</button>
|
);
|
||||||
);
|
}
|
||||||
})}
|
return (
|
||||||
|
<div key={category}>
|
||||||
|
<h4 className="px-1 mb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500 dark:text-neutral-400">
|
||||||
|
{t(`email.categories.${category}`, category)}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{byCategory[category].map(renderTemplate)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
Reference in New Issue
Block a user