Merge pull request #464 from Luca-Timo/feat/email-templates-reorg

Feat/email templates reorg
This commit is contained in:
Paul Nothaft
2026-05-11 21:44:17 +02:00
committed by GitHub
12 changed files with 1487 additions and 15 deletions
+17 -1
View File
@@ -2226,7 +2226,23 @@
"mutedTextColor": "Fußzeilentext",
"mutedTextColorHelp": "Fußzeilentext und Copyright-Zeile. Entspricht Branding → Sekundärer Text.",
"buttonTextColor": "Button-Text",
"buttonTextColorHelp": "Textfarbe auf gefüllten Buttons. Sollte klar gegen die Primärfarbe kontrastieren. Kein Branding-Äquivalent — meistens weiß."
"buttonTextColorHelp": "Textfarbe auf gefüllten Buttons. Sollte klar gegen die Primärfarbe kontrastieren. Kein Branding-Äquivalent — meistens weiß.",
"categories": {
"core": "Kern",
"customers": "Kunden",
"calendar": "Kalender",
"quotes": "Angebote",
"billing": "Rechnungen"
},
"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.",
"subcategories": {
"gallery": "Galerien",
"admin": "Admin-Konten",
"backup": "Backup & Wiederherstellung",
"system": "System-Updates",
"other": "Sonstige"
}
},
"cms": {
"title": "CMS-Seiten",
+17 -1
View File
@@ -1880,7 +1880,23 @@
"mutedTextColor": "Footer text",
"mutedTextColorHelp": "Footer text and copyright line. Maps to Branding → Secondary text.",
"buttonTextColor": "Button text",
"buttonTextColorHelp": "Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white."
"buttonTextColorHelp": "Text colour on filled buttons. Should contrast cleanly against the Primary colour. No Branding equivalent — usually white.",
"categories": {
"core": "Core",
"customers": "Customers",
"calendar": "Calendar",
"quotes": "Quotes",
"billing": "Billing"
},
"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.",
"subcategories": {
"gallery": "Galleries",
"admin": "Admin accounts",
"backup": "Backup & restore",
"system": "System updates",
"other": "Other"
}
},
"cms": {
"title": "CMS Pages",
+16
View File
@@ -1826,6 +1826,22 @@
"enterUrl": "Saisir une URL…",
"addLink": "Ajouter",
"cancel": "Annuler"
},
"categories": {
"core": "Principal",
"customers": "Clients",
"calendar": "Calendrier",
"quotes": "Devis",
"billing": "Facturation"
},
"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.",
"subcategories": {
"gallery": "Galeries",
"admin": "Comptes administrateurs",
"backup": "Sauvegarde & restauration",
"system": "Mises à jour système",
"other": "Autres"
}
},
"cms": {
+17 -1
View File
@@ -1880,7 +1880,23 @@
"mutedTextColor": "Voetteksttekst",
"mutedTextColorHelp": "Voetteksttekst en copyrightregel. Komt overeen met Huisstijl → Secundaire tekst.",
"buttonTextColor": "Knoptekst",
"buttonTextColorHelp": "Tekstkleur op gevulde knoppen. Moet goed contrasteren met de primaire kleur. Doorgaans wit."
"buttonTextColorHelp": "Tekstkleur op gevulde knoppen. Moet goed contrasteren met de primaire kleur. Doorgaans wit.",
"categories": {
"core": "Kern",
"customers": "Klanten",
"calendar": "Agenda",
"quotes": "Offertes",
"billing": "Facturatie"
},
"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.",
"subcategories": {
"gallery": "Galerijen",
"admin": "Adminaccounts",
"backup": "Back-up & herstel",
"system": "Systeemupdates",
"other": "Overige"
}
},
"cms": {
"title": "CMS-pagina's",
+17 -1
View File
@@ -1905,7 +1905,23 @@
"mutedTextColor": "Texto do rodapé",
"mutedTextColorHelp": "Texto do rodapé e linha de copyright. Corresponde a Identidade Visual → Texto secundário.",
"buttonTextColor": "Texto dos botões",
"buttonTextColorHelp": "Cor do texto em botões preenchidos. Deve contrastar bem com a cor primária. Geralmente branco."
"buttonTextColorHelp": "Cor do texto em botões preenchidos. Deve contrastar bem com a cor primária. Geralmente branco.",
"categories": {
"core": "Principal",
"customers": "Clientes",
"calendar": "Calendário",
"quotes": "Orçamentos",
"billing": "Faturamento"
},
"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.",
"subcategories": {
"gallery": "Galerias",
"admin": "Contas de administrador",
"backup": "Backup & restauração",
"system": "Atualizações do sistema",
"other": "Outros"
}
},
"cms": {
"title": "Páginas CMS",
+17 -1
View File
@@ -1930,7 +1930,23 @@
"mutedTextColor": "Текст подвала",
"mutedTextColorHelp": "Текст подвала и строка авторского права. Соответствует Брендинг → Дополнительный текст.",
"buttonTextColor": "Текст кнопок",
"buttonTextColorHelp": "Цвет текста на заполненных кнопках. Должен хорошо контрастировать с основным цветом. Обычно белый."
"buttonTextColorHelp": "Цвет текста на заполненных кнопках. Должен хорошо контрастировать с основным цветом. Обычно белый.",
"categories": {
"core": "Основное",
"customers": "Клиенты",
"calendar": "Календарь",
"quotes": "Сметы",
"billing": "Счета"
},
"featureOff": "Функция выключена",
"featureOffTooltip": "Связанная функция в данный момент отключена. Вы по-прежнему можете редактировать шаблон — он будет использоваться после повторного включения функции.",
"subcategories": {
"gallery": "Галереи",
"admin": "Учётные записи администраторов",
"backup": "Резервное копирование и восстановление",
"system": "Системные обновления",
"other": "Прочее"
}
},
"cms": {
"title": "Страницы CMS",
+129 -10
View File
@@ -24,6 +24,37 @@ import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateT
import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next';
import { SUPPORTED_LANGUAGES } from "../../components/common/LanguageSelector.tsx";
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
/**
* Template categorisation (migration 098). Sidebar sections render
* in this order. Empty categories are hidden automatically. New
* categories: add the key here, give it an i18n label
* (email.categories.<key>), set `feature_flag` on templates that
* should chip out when the matching flag is off. No other UI
* changes required.
*/
const CATEGORY_ORDER: readonly string[] = [
'core',
'customers',
'calendar',
'quotes',
'billing',
] 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 = [
{
@@ -117,6 +148,7 @@ export const EmailConfigPage: React.FC = () => {
const [emailMutedTextColor, setEmailMutedTextColor] = useState('#666666');
const [emailButtonTextColor, setEmailButtonTextColor] = useState('#ffffff');
const queryClient = useQueryClient();
const { flags: featureFlags } = useFeatureFlags();
// SMTP Configuration state
const [smtpConfig, setSmtpConfig] = useState<EmailConfig>({
@@ -714,11 +746,36 @@ export const EmailConfigPage: React.FC = () => {
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<Card padding="sm">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('email.templates')}</h3>
<div className="space-y-2">
{templates.map(template => {
const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key);
{/* Templates grouped by category (migration 098). Categories
in CATEGORY_ORDER render in sequence; templates that
report an unrecognised category fall into 'core' so a
forward-compat row never disappears from the UI.
Empty categories are hidden — admins don't see a
section header with no body. Templates whose
feature_flag is currently false stay fully visible and
editable, just chip-tagged so the admin knows the
feature is dormant. */}
{(() => {
// 1. Bucket templates by top-level category (forward-compat:
// unknown categories fall into 'core').
const byCategory: Record<string, EmailTemplate[]> = {};
for (const template of templates) {
const cat = CATEGORY_ORDER.includes(template.category || 'core')
? (template.category || 'core')
: 'core';
(byCategory[cat] = byCategory[cat] || []).push(template);
}
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}
@@ -732,21 +789,83 @@ export const EmailConfigPage: React.FC = () => {
: '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">
<p className="font-medium text-neutral-900 dark:text-neutral-100">
<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>
<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 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>
);
})}
</div>
};
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>
);
}
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>
<div className="lg:col-span-2">
+26
View File
@@ -17,11 +17,37 @@ export interface EmailTemplateTranslation {
body_text?: string;
}
/**
* Top-level grouping in the admin Templates UI. Forward-compatible:
* unknown values fall back to the 'core' section.
*/
export type EmailTemplateCategory = 'core' | 'customers' | 'billing' | 'quotes' | 'calendar' | string;
/**
* Second-level grouping inside 'core' (which is busy enough to deserve
* its own sub-headers). Other categories ignore this field.
*/
export type EmailTemplateSubcategory = 'gallery' | 'admin' | 'backup' | 'system' | string;
export interface EmailTemplate {
id: number;
template_key: string;
variables: string[];
translations: Record<string, EmailTemplateTranslation>;
/** Display group (migration 098). Defaults to 'core' if absent. */
category?: EmailTemplateCategory;
/**
* Second-level group inside `core`. Migration 098 backfill assigns
* one of 'gallery' | 'admin' | 'backup' | 'system'; NULL for
* templates outside `core`.
*/
subcategory?: EmailTemplateSubcategory | null;
/**
* Name of the feature flag whose `false` state should mark this
* template as "Feature off" in the admin UI. `null` = always
* active. Migration 098 backfills these.
*/
feature_flag?: string | null;
updated_at: string;
}