feat(email-templates): group Templates UI by category with core sub-sections

This commit is contained in:
Luca
2026-05-11 20:56:50 +02:00
parent 2cae3fe47d
commit 53eecb6f83
7 changed files with 164 additions and 64 deletions
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -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",
+8 -1
View File
@@ -1939,7 +1939,14 @@
"billing": "Счета" "billing": "Счета"
}, },
"featureOff": "Функция выключена", "featureOff": "Функция выключена",
"featureOffTooltip": "Связанная функция в данный момент отключена. Вы по-прежнему можете редактировать шаблон — он будет использоваться после повторного включения функции." "featureOffTooltip": "Связанная функция в данный момент отключена. Вы по-прежнему можете редактировать шаблон — он будет использоваться после повторного включения функции.",
"subcategories": {
"gallery": "Галереи",
"admin": "Учётные записи администраторов",
"backup": "Резервное копирование и восстановление",
"system": "Системные обновления",
"other": "Прочее"
}
}, },
"cms": { "cms": {
"title": "Страницы CMS", "title": "Страницы CMS",
+76 -18
View File
@@ -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,24 +756,20 @@ 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);
return ( // 2. Renders a single template button. Pulled out so the
<div className="space-y-5"> // flat path and the sub-category path share it.
{visibleSections.map((category) => ( const renderTemplate = (template: EmailTemplate) => {
<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">
{grouped[category].map((template) => {
const templateInfo = defaultTemplateKeys.find((t) => t.key === template.template_key); const templateInfo = defaultTemplateKeys.find((t) => t.key === template.template_key);
const translationCount = getTranslationCount(template); const translationCount = getTranslationCount(template);
const enTranslation = template.translations?.en; const enTranslation = template.translations?.en;
@@ -801,11 +812,58 @@ export const EmailConfigPage: React.FC = () => {
</p> </p>
</button> </button>
); );
})} };
return (
<div className="space-y-5">
{visibleCategories.map((category) => {
// Inside 'core' we group templates further by
// subcategory so the busy bucket reads cleanly.
// Other categories render their templates flat.
if (category === 'core') {
const bySub: Record<string, EmailTemplate[]> = {};
for (const template of byCategory.core) {
const sub = CORE_SUBCATEGORY_ORDER.includes(template.subcategory || '')
? (template.subcategory as string)
: 'other';
(bySub[sub] = bySub[sub] || []).push(template);
}
const visibleSubs = [
...CORE_SUBCATEGORY_ORDER.filter((s) => bySub[s]?.length),
...(bySub.other?.length ? ['other'] : []),
];
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-4 pl-1">
{visibleSubs.map((sub) => (
<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)}
</h5>
<div className="space-y-2">
{bySub[sub].map(renderTemplate)}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div>
);
}
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>
); );
})()} })()}
</Card> </Card>