Merge pull request #258 from the-luap/feat/email-template-translations

feat: multilingual email templates with translations table
This commit is contained in:
Paul Nothaft
2026-04-04 17:43:29 +02:00
committed by GitHub
11 changed files with 687 additions and 255 deletions
@@ -0,0 +1,281 @@
/**
* Migration to create email_template_translations table
* Moves from per-column language support (subject_en, subject_de) to a
* normalized translations table where each language is a row.
* This allows adding new languages without schema changes.
*/
exports.up = async function(knex) {
// 1. Create the email_template_translations table
await knex.schema.createTable('email_template_translations', (table) => {
table.increments('id').primary();
table.integer('template_id').unsigned().notNullable()
.references('id').inTable('email_templates').onDelete('CASCADE');
table.string('language', 10).notNullable();
table.text('subject');
table.text('body_html');
table.text('body_text');
table.datetime('created_at').defaultTo(knex.fn.now());
table.datetime('updated_at').defaultTo(knex.fn.now());
table.unique(['template_id', 'language']);
});
console.log('Created email_template_translations table');
// 2. Migrate existing data from email_templates columns into rows
const templates = await knex('email_templates').select('*');
const columnInfo = await knex('email_templates').columnInfo();
const hasLangColumns = !!columnInfo.subject_en;
for (const template of templates) {
// Extract EN translation
const enSubject = hasLangColumns
? (template.subject_en || template.subject || '')
: (template.subject || '');
const enHtml = hasLangColumns
? (template.body_html_en || template.body_html || '')
: (template.body_html || '');
const enText = hasLangColumns
? (template.body_text_en || template.body_text || '')
: (template.body_text || '');
// Insert EN translation
if (enSubject || enHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'en',
subject: enSubject,
body_html: enHtml,
body_text: enText,
created_at: new Date(),
updated_at: new Date(),
});
}
// Extract DE translation (only if lang columns exist)
if (hasLangColumns) {
const deSubject = template.subject_de || '';
const deHtml = template.body_html_de || '';
const deText = template.body_text_de || '';
// Only insert if DE content differs from EN or has content
if (deSubject || deHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'de',
subject: deSubject,
body_html: deHtml,
body_text: deText,
created_at: new Date(),
updated_at: new Date(),
});
}
}
}
console.log(`Migrated ${templates.length} templates to translations table`);
// 3. Seed NL, PT, RU translations for customer-facing templates
// Look up template IDs
const customerTemplates = await knex('email_templates')
.whereIn('template_key', [
'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete'
])
.select('id', 'template_key');
const templateMap = {};
customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; });
const seedTranslations = [];
// --- gallery_created ---
if (templateMap.gallery_created) {
const id = templateMap.gallery_created;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij is klaar!',
body_html: `<h2>Galerij succesvol aangemaakt</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!</p>
<p><strong>Galerij details:</strong></p>
<ul>
<li>Evenementdatum: {{event_date}}</li>
<li>Galerij link: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Wachtwoord: {{gallery_password}}</li>
<li>Verloopt op: {{expiry_date}}</li>
</ul>
<p>Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos está pronta!',
body_html: `<h2>Galeria criada com sucesso</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" foi criada com sucesso!</p>
<p><strong>Detalhes da galeria:</strong></p>
<ul>
<li>Data do evento: {{event_date}}</li>
<li>Link da galeria: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Senha: {{gallery_password}}</li>
<li>Expira em: {{expiry_date}}</li>
</ul>
<p>Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`,
},
{
template_id: id, language: 'ru',
subject: 'Ваша фотогалерея готова!',
body_html: `<h2>Галерея успешно создана</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Ваша фотогалерея "{{event_name}}" была успешно создана!</p>
<p><strong>Детали галереи:</strong></p>
<ul>
<li>Дата события: {{event_date}}</li>
<li>Ссылка на галерею: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Пароль: {{gallery_password}}</li>
<li>Срок действия: {{expiry_date}}</li>
</ul>
<p>Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`,
},
);
}
// --- expiration_warning ---
if (templateMap.expiration_warning) {
const id = templateMap.expiration_warning;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij verloopt binnenkort',
body_html: `<h2>Galerij verloopt binnenkort</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.</p>
<p>Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.</p>
<p><a href="{{gallery_link}}">Galerij bezoeken</a></p>`,
body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos expira em breve',
body_html: `<h2>Galeria expirando em breve</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.</p>
<p>Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.</p>
<p><a href="{{gallery_link}}">Visitar galeria</a></p>`,
body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия вашей фотогалереи скоро истекает',
body_html: `<h2>Срок действия галереи истекает</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.</p>
<p>После истечения срока галерея будет архивирована и станет недоступна для гостей.</p>
<p><a href="{{gallery_link}}">Перейти в галерею</a></p>`,
body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`,
},
);
}
// --- gallery_expired ---
if (templateMap.gallery_expired) {
const id = templateMap.gallery_expired;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij {{event_name}} is verlopen',
body_html: `<h2>Galerij verlopen</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.</p>
<p>De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.</p>`,
body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos {{event_name}} expirou',
body_html: `<h2>Galeria expirada</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.</p>
<p>As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.</p>`,
body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия фотогалереи {{event_name}} истёк',
body_html: `<h2>Срок действия галереи истёк</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.</p>
<p>Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.</p>`,
body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`,
},
);
}
// --- archive_complete ---
if (templateMap.archive_complete) {
const id = templateMap.archive_complete;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Archivering voltooid: {{event_name}}',
body_html: `<h2>Archivering voltooid</h2>
<p>Beste {{host_name}},</p>
<p>De fotogalerij "{{event_name}}" is succesvol gearchiveerd.</p>
<p><strong>Archief details:</strong></p>
<ul>
<li>Aantal foto's: {{photo_count}}</li>
<li>Archiefgrootte: {{archive_size}}</li>
<li>Archiefdatum: {{archive_date}}</li>
</ul>`,
body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`,
},
{
template_id: id, language: 'pt',
subject: 'Arquivamento concluído: {{event_name}}',
body_html: `<h2>Arquivamento concluído</h2>
<p>Prezado(a) {{host_name}},</p>
<p>A galeria de fotos "{{event_name}}" foi arquivada com sucesso.</p>
<p><strong>Detalhes do arquivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamanho do arquivo: {{archive_size}}</li>
<li>Data do arquivamento: {{archive_date}}</li>
</ul>`,
body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`,
},
{
template_id: id, language: 'ru',
subject: 'Архивация завершена: {{event_name}}',
body_html: `<h2>Архивация завершена</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Фотогалерея "{{event_name}}" была успешно архивирована.</p>
<p><strong>Детали архива:</strong></p>
<ul>
<li>Количество фото: {{photo_count}}</li>
<li>Размер архива: {{archive_size}}</li>
<li>Дата архивации: {{archive_date}}</li>
</ul>`,
body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`,
},
);
}
// Insert all seed translations
const now = new Date();
for (const trans of seedTranslations) {
trans.created_at = now;
trans.updated_at = now;
await knex('email_template_translations').insert(trans);
}
console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`);
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('email_template_translations');
};
+152 -124
View File
@@ -248,19 +248,8 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
} }
}); });
// Get email templates // Helper: parse variables JSON safely
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => { function parseVariables(template) {
try {
const templates = await db('email_templates')
.select('*')
.orderBy('template_key');
// Parse variables JSON and format for multi-language support
const formattedTemplates = templates.map(template => {
const result = {
id: template.id,
template_key: template.template_key,
variables: (() => {
try { try {
if (!template.variables) return []; if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables; if (typeof template.variables === 'object') return template.variables;
@@ -269,31 +258,66 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
console.warn('Failed to parse variables for template:', template.template_key, e.message); console.warn('Failed to parse variables for template:', template.template_key, e.message);
return []; return [];
} }
})(), }
updated_at: template.updated_at
// Helper: get translations for a template, with legacy column fallback
async function getTemplateTranslations(templateId, template) {
const translations = {};
try {
const rows = await db('email_template_translations')
.where('template_id', templateId)
.select('language', 'subject', 'body_html', 'body_text');
rows.forEach(row => {
translations[row.language] = {
subject: row.subject || '',
body_html: row.body_html || '',
body_text: row.body_text || '',
}; };
// Handle both old and new schema formats
if (template.subject_en !== undefined) {
// New schema with language columns
result.subject_en = template.subject_en;
result.body_html_en = template.body_html_en;
result.body_text_en = template.body_text_en;
result.subject_de = template.subject_de;
result.body_html_de = template.body_html_de;
result.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
result.subject_en = template.subject;
result.body_html_en = template.body_html;
result.body_text_en = template.body_text;
result.subject_de = template.subject;
result.body_html_de = template.body_html;
result.body_text_de = template.body_text;
}
return result;
}); });
} catch (error) {
// Translations table might not exist yet (pre-migration)
// Fall back to legacy columns
if (template.subject_en !== undefined) {
translations.en = {
subject: template.subject_en || '',
body_html: template.body_html_en || '',
body_text: template.body_text_en || '',
};
translations.de = {
subject: template.subject_de || '',
body_html: template.body_html_de || '',
body_text: template.body_text_de || '',
};
} else {
translations.en = {
subject: template.subject || '',
body_html: template.body_html || '',
body_text: template.body_text || '',
};
}
}
return translations;
}
// Get email templates
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
const templates = await db('email_templates')
.select('*')
.orderBy('template_key');
const formattedTemplates = [];
for (const template of templates) {
const translations = await getTemplateTranslations(template.id, template);
formattedTemplates.push({
id: template.id,
template_key: template.template_key,
variables: parseVariables(template),
translations,
updated_at: template.updated_at,
});
}
res.json(formattedTemplates); res.json(formattedTemplates);
} catch (error) { } catch (error) {
@@ -313,75 +337,27 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
return res.status(404).json({ error: 'Template not found' }); return res.status(404).json({ error: 'Template not found' });
} }
// Handle both old and new schema formats const translations = await getTemplateTranslations(template.id, template);
const response = {
res.json({
id: template.id, id: template.id,
template_key: template.template_key, template_key: template.template_key,
variables: (() => { variables: parseVariables(template),
try { translations,
if (!template.variables) return []; updated_at: template.updated_at,
if (typeof template.variables === 'object') return template.variables; });
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
};
// Check which columns exist and use them appropriately
if (template.subject_en !== undefined) {
// New schema with language columns
response.subject_en = template.subject_en;
response.body_html_en = template.body_html_en;
response.body_text_en = template.body_text_en;
response.subject_de = template.subject_de;
response.body_html_de = template.body_html_de;
response.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
response.subject_en = template.subject;
response.body_html_en = template.body_html;
response.body_text_en = template.body_text;
response.subject_de = template.subject;
response.body_html_de = template.body_html;
response.body_text_de = template.body_text;
}
res.json(response);
} catch (error) { } catch (error) {
console.error('Email template fetch error:', error); console.error('Email template fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email template' }); res.status(500).json({ error: 'Failed to fetch email template' });
} }
}); });
// Update email template // Update email template translations
router.put('/templates/:key', [ router.put('/templates/:key', [
adminAuth, adminAuth,
requirePermission('email.edit'), requirePermission('email.edit'),
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty')
], async (req, res) => { ], async (req, res) => {
try { try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
subject_en, subject_de,
body_html_en, body_html_de,
body_text_en, body_text_de
} = req.body;
const updateData = {
updated_at: new Date()
};
// Check which columns exist in the database
const template = await db('email_templates') const template = await db('email_templates')
.where('template_key', req.params.key) .where('template_key', req.params.key)
.first(); .first();
@@ -390,42 +366,70 @@ router.put('/templates/:key', [
return res.status(404).json({ error: 'Template not found' }); return res.status(404).json({ error: 'Template not found' });
} }
// Determine schema type and update accordingly const { translations } = req.body;
if (template.subject_en !== undefined) {
// New schema with language columns
if (subject_en !== undefined) updateData.subject_en = subject_en;
if (subject_de !== undefined) updateData.subject_de = subject_de;
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
// Also update basic columns if they exist if (!translations || typeof translations !== 'object') {
if (template.subject !== undefined) { return res.status(400).json({ error: 'translations object is required' });
updateData.subject = subject_en || updateData.subject_en;
updateData.body_html = body_html_en || updateData.body_html_en;
updateData.body_text = body_text_en || updateData.body_text_en || '';
} }
// Upsert each language translation
for (const [language, data] of Object.entries(translations)) {
if (!data || typeof data !== 'object') continue;
const existing = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
const row = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || '',
updated_at: new Date(),
};
if (existing) {
await db('email_template_translations')
.where({ template_id: template.id, language })
.update(row);
} else { } else {
// Old schema - only update basic columns await db('email_template_translations').insert({
if (subject_en !== undefined) { template_id: template.id,
updateData.subject = subject_en; language,
updateData.body_html = body_html_en; ...row,
updateData.body_text = body_text_en || ''; created_at: new Date(),
});
} }
} }
const updated = await db('email_templates') // Update timestamp on parent template
.where('template_key', req.params.key) await db('email_templates')
.update(updateData); .where('id', template.id)
.update({ updated_at: new Date() });
if (!updated) { // Also sync legacy columns for backward compatibility
return res.status(404).json({ error: 'Template not found' }); const enData = translations.en;
const deData = translations.de;
const legacyUpdate = { updated_at: new Date() };
const columnInfo = await db('email_templates').columnInfo();
if (enData && columnInfo.subject_en) {
legacyUpdate.subject_en = enData.subject || '';
legacyUpdate.body_html_en = enData.body_html || '';
legacyUpdate.body_text_en = enData.body_text || '';
} }
if (deData && columnInfo.subject_de) {
legacyUpdate.subject_de = deData.subject || '';
legacyUpdate.body_html_de = deData.body_html || '';
legacyUpdate.body_text_de = deData.body_text || '';
}
await db('email_templates')
.where('id', template.id)
.update(legacyUpdate);
// Log activity // Log activity
await logActivity('email_template_updated', await logActivity('email_template_updated',
{ template_key: req.params.key }, { template_key: req.params.key, languages: Object.keys(translations) },
null, null,
{ type: 'admin', id: req.admin.id, name: req.admin.username } { type: 'admin', id: req.admin.id, name: req.admin.username }
); );
@@ -450,15 +454,39 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
const { preview_data, language = 'en' } = req.body; const { preview_data, language = 'en' } = req.body;
// Get the appropriate language version // Get translation from translations table with fallback
let translation = null;
try {
translation = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
if (!translation && language !== 'en') {
translation = await db('email_template_translations')
.where({ template_id: template.id, language: 'en' })
.first();
}
} catch (e) {
// Fallback to legacy columns
}
let subject = '';
let htmlContent = '';
let textContent = '';
if (translation) {
subject = translation.subject || '';
htmlContent = translation.body_html || '';
textContent = translation.body_text || '';
} else {
// Legacy column fallback
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en'; const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en'; const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en'; const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
subject = template[subjectField] || template.subject || '';
// Handle backward compatibility htmlContent = template[htmlField] || template.body_html || '';
let htmlContent = template[htmlField] || template.body_html || ''; textContent = template[textField] || template.body_text || '';
let textContent = template[textField] || template.body_text || ''; }
let subject = template[subjectField] || template.subject || '';
if (preview_data) { if (preview_data) {
const escapeHtml = (str) => String(str) const escapeHtml = (str) => String(str)
@@ -472,7 +500,7 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
const regex = new RegExp(`{{${key}}}`, 'g'); const regex = new RegExp(`{{${key}}}`, 'g');
const escapedValue = escapeHtml(preview_data[key]); const escapedValue = escapeHtml(preview_data[key]);
htmlContent = htmlContent.replace(regex, escapedValue); htmlContent = htmlContent.replace(regex, escapedValue);
textContent = textContent.replace(regex, preview_data[key]); // text doesn't need HTML escaping textContent = textContent.replace(regex, preview_data[key]);
subject = subject.replace(regex, escapeHtml(preview_data[key])); subject = subject.replace(regex, escapeHtml(preview_data[key]));
}); });
} }
+72 -16
View File
@@ -99,12 +99,19 @@ async function getRecipientLanguage(email, eventId = null) {
logger.error('Error fetching email config language:', error); logger.error('Error fetching email config language:', error);
} }
// Fourth priority: Check if the email domain suggests German // Fourth priority: Check if the email domain suggests a language
if (email) { if (email) {
const germanDomains = ['.de', '.at', '.ch', '.li'];
const domain = email.toLowerCase(); const domain = email.toLowerCase();
if (germanDomains.some(d => domain.endsWith(d))) { const domainLanguageMap = [
return 'de'; { domains: ['.de', '.at', '.ch', '.li'], language: 'de' },
{ domains: ['.nl', '.be'], language: 'nl' },
{ domains: ['.br', '.pt'], language: 'pt' },
{ domains: ['.ru', '.su'], language: 'ru' },
];
for (const { domains, language: lang } of domainLanguageMap) {
if (domains.some(d => domain.endsWith(d))) {
return lang;
}
} }
} }
@@ -300,30 +307,73 @@ async function processTemplate(template, variables, language = 'en') {
const { formatDate } = require('../utils/dateFormatter'); const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters'); const { formatWelcomeMessage } = require('../utils/formatters');
// Get the appropriate language fields // Get translation from email_template_translations table with fallback chain
let subject = '';
let htmlBody = '';
let textBody = '';
try {
// Try requested language first, then English, then any available
let translation = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
if (!translation && language !== 'en') {
translation = await db('email_template_translations')
.where({ template_id: template.id, language: 'en' })
.first();
}
if (!translation) {
translation = await db('email_template_translations')
.where({ template_id: template.id })
.first();
}
if (translation) {
subject = translation.subject || '';
htmlBody = translation.body_html || '';
textBody = translation.body_text || '';
}
} catch (error) {
logger.warn('email_template_translations table not available, falling back to columns:', error.message);
}
// Fallback to legacy column-based fields if no translation found
if (!subject && !htmlBody) {
const subjectField = language === 'de' ? 'subject_de' : 'subject_en'; const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en'; const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' ? 'body_text_de' : 'body_text_en'; const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
subject = template[subjectField] || template.subject_en || template.subject || '';
// Fall back to non-language-specific fields for backward compatibility htmlBody = template[htmlField] || template.body_html_en || template.body_html || '';
let subject = template[subjectField] || template.subject || ''; textBody = template[textField] || template.body_text_en || template.body_text || '';
let htmlBody = template[htmlField] || template.body_html || ''; }
let textBody = template[textField] || template.body_text || '';
// Process variables before template compilation // Process variables before template compilation
const processedVariables = { ...variables }; const processedVariables = { ...variables };
// Handle password security message // Handle password security message
const passwordSecurityI18n = {
en: '(Not shown for security reasons)',
de: '(Aus Sicherheitsgründen nicht angezeigt)',
nl: '(Om veiligheidsredenen niet weergegeven)',
pt: '(Não exibido por motivos de segurança)',
ru: '(Не показано в целях безопасности)',
};
const noPasswordI18n = {
en: 'No password required',
de: 'Kein Passwort erforderlich',
nl: 'Geen wachtwoord vereist',
pt: 'Nenhuma senha necessária',
ru: 'Пароль не требуется',
};
if (processedVariables.gallery_password === '{{password_security_message}}') { if (processedVariables.gallery_password === '{{password_security_message}}') {
processedVariables.gallery_password = language === 'de' processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)';
} }
if (processedVariables.gallery_password === 'No password required') { if (processedVariables.gallery_password === 'No password required') {
processedVariables.gallery_password = language === 'de' processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
? 'Kein Passwort erforderlich'
: 'No password required';
} }
// Format dates if they exist // Format dates if they exist
@@ -371,6 +421,12 @@ async function processTemplate(template, variables, language = 'en') {
link: 'Открыть доступ клиента', link: 'Открыть доступ клиента',
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.', warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
}, },
nl: {
label: 'Klanttoegang (Privé)',
desc: 'Bekijk en beheer de zichtbaarheid van foto\'s voordat u deelt met gasten:',
link: 'Klanttoegang openen',
warning: 'Deel deze link niet — hiermee kunnen foto\'s worden verborgen in de gastengalerij.',
},
pt: { pt: {
label: 'Acesso do Cliente (Privado)', label: 'Acesso do Cliente (Privado)',
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:', desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
@@ -246,6 +246,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
> >
<option value="en">English</option> <option value="en">English</option>
<option value="de">Deutsch</option> <option value="de">Deutsch</option>
<option value="nl">Nederlands</option>
<option value="pt">Português (Brasil)</option> <option value="pt">Português (Brasil)</option>
<option value="ru">Русский</option> <option value="ru">Русский</option>
</select> </select>
+5 -1
View File
@@ -2149,7 +2149,11 @@
"enterUrl": "URL eingeben...", "enterUrl": "URL eingeben...",
"addLink": "Hinzufügen", "addLink": "Hinzufügen",
"cancel": "Abbrechen" "cancel": "Abbrechen"
} },
"copiedFromLanguage": "Inhalt von {{language}} kopiert",
"noTranslation": "Noch keine Übersetzung",
"noTranslationYet": "Für diese Sprache existiert noch keine Übersetzung. Kopieren Sie von einer vorhandenen Sprache:",
"copyFrom": "Kopieren von"
}, },
"cms": { "cms": {
"title": "CMS-Seiten", "title": "CMS-Seiten",
+5 -1
View File
@@ -1698,7 +1698,11 @@
"enterUrl": "Enter URL...", "enterUrl": "Enter URL...",
"addLink": "Add", "addLink": "Add",
"cancel": "Cancel" "cancel": "Cancel"
} },
"copiedFromLanguage": "Copied content from {{language}}",
"noTranslation": "No translation yet",
"noTranslationYet": "No translation exists for this language yet. Copy from an existing language to get started:",
"copyFrom": "Copy from"
}, },
"cms": { "cms": {
"title": "CMS Pages", "title": "CMS Pages",
+15 -18
View File
@@ -433,7 +433,7 @@
"loadingPhotos": "Foto's laden...", "loadingPhotos": "Foto's laden...",
"photoCategories": "Fotocategorieen", "photoCategories": "Fotocategorieen",
"organizeCategoriesInfo": "Organiseer uw foto's in categorieen. Categorieen helpen gasten om specifieke soorten foto's te vinden.", "organizeCategoriesInfo": "Organiseer uw foto's in categorieen. Categorieen helpen gasten om specifieke soorten foto's te vinden.",
"categoriesTip": "Tip: Categorieen zijn specifiek per evenement. U kunt ook globale categorieen aanmaken in Instellingen.", "categoriesTip": "Tip: Categorieen zijn specifiek per evenement. U kunt aangepaste categorieen aanmaken zoals \"Ceremonie\", \"Receptie\", \"Portretten\", etc.",
"contactInformation": "Contactgegevens", "contactInformation": "Contactgegevens",
"hostEmailHelp": "Klant ontvangt meldingen over aanmaak en verloop van de galerij", "hostEmailHelp": "Klant ontvangt meldingen over aanmaak en verloop van de galerij",
"adminEmailHelp": "Ontvangt systeemmeldingen en archiefbevestigingen", "adminEmailHelp": "Ontvangt systeemmeldingen en archiefbevestigingen",
@@ -569,19 +569,13 @@
"guestsCannotAccessGallery": "Gasten hebben geen toegang meer tot de galerij. Overweeg dit evenement te archiveren.", "guestsCannotAccessGallery": "Gasten hebben geen toegang meer tot de galerij. Overweeg dit evenement te archiveren.",
"warningEmailsHaveBeenSent": "Waarschuwingsmails zijn naar de klant verzonden.", "warningEmailsHaveBeenSent": "Waarschuwingsmails zijn naar de klant verzonden.",
"extendSevenDays": "7 dagen verlengen", "extendSevenDays": "7 dagen verlengen",
"eventInformation": "Evenementinformatie",
"welcomeMessageLabel": "Welkomstbericht", "welcomeMessageLabel": "Welkomstbericht",
"noWelcomeMessageSet": "Geen welkomstbericht ingesteld", "noWelcomeMessageSet": "Geen welkomstbericht ingesteld",
"hostEmail": "E-mail klant",
"adminEmail": "E-mail beheerder",
"createdOn": "Aangemaakt", "createdOn": "Aangemaakt",
"shareLink": "Deellink",
"copy": "Kopieren", "copy": "Kopieren",
"copied": "Gekopieerd!", "copied": "Gekopieerd!",
"organizingPhotosInfo": "Organiseer uw foto's in categorieen. Categorieen helpen gasten om specifieke soorten foto's te vinden.", "organizingPhotosInfo": "Organiseer uw foto's in categorieen. Categorieen helpen gasten om specifieke soorten foto's te vinden.",
"categoriesTip": "Tip: Categorieen zijn specifiek per evenement. U kunt aangepaste categorieen aanmaken zoals \"Ceremonie\", \"Receptie\", \"Portretten\", etc.",
"archiveStatusTitle": "Archiefstatus", "archiveStatusTitle": "Archiefstatus",
"archivedOn": "Gearchiveerd op",
"downloadingArchive": "Archief {{name}} downloaden...", "downloadingArchive": "Archief {{name}} downloaden...",
"downloadStarted": "Download gestart", "downloadStarted": "Download gestart",
"failedToDownloadArchive": "Kan archief niet downloaden", "failedToDownloadArchive": "Kan archief niet downloaden",
@@ -1704,7 +1698,11 @@
"enterUrl": "Voer URL in...", "enterUrl": "Voer URL in...",
"addLink": "Toevoegen", "addLink": "Toevoegen",
"cancel": "Annuleren" "cancel": "Annuleren"
} },
"copiedFromLanguage": "Inhoud gekopieerd van {{language}}",
"noTranslation": "Nog geen vertaling",
"noTranslationYet": "Er bestaat nog geen vertaling voor deze taal. Kopieer van een bestaande taal om te beginnen:",
"copyFrom": "Kopiëren van"
}, },
"cms": { "cms": {
"title": "CMS-pagina's", "title": "CMS-pagina's",
@@ -2003,7 +2001,15 @@
"duration": "Duur", "duration": "Duur",
"actions": "Acties" "actions": "Acties"
}, },
"details": "Details", "details": {
"backupDetails": "Back-updetails",
"destination": "Bestemming",
"started": "Gestart",
"completed": "Voltooid",
"contentBackedUp": "Geback-upte inhoud",
"errorDetails": "Foutdetails",
"manifest": "Manifest"
},
"statistics": "Statistieken", "statistics": "Statistieken",
"errors": "Fouten", "errors": "Fouten",
"backupDetails": { "backupDetails": {
@@ -2033,15 +2039,6 @@
"backupsWillAppear": "Back-ups verschijnen hier zodra ze zijn aangemaakt", "backupsWillAppear": "Back-ups verschijnen hier zodra ze zijn aangemaakt",
"messages": { "messages": {
"deleteSuccess": "Back-up succesvol verwijderd" "deleteSuccess": "Back-up succesvol verwijderd"
},
"details": {
"backupDetails": "Back-updetails",
"destination": "Bestemming",
"started": "Gestart",
"completed": "Voltooid",
"contentBackedUp": "Geback-upte inhoud",
"errorDetails": "Foutdetails",
"manifest": "Manifest"
} }
}, },
"restore": { "restore": {
+5 -1
View File
@@ -1698,7 +1698,11 @@
"enterUrl": "Digite a URL...", "enterUrl": "Digite a URL...",
"addLink": "Adicionar", "addLink": "Adicionar",
"cancel": "Cancelar" "cancel": "Cancelar"
} },
"copiedFromLanguage": "Conteúdo copiado de {{language}}",
"noTranslation": "Ainda sem tradução",
"noTranslationYet": "Ainda não existe tradução para este idioma. Copie de um idioma existente para começar:",
"copyFrom": "Copiar de"
}, },
"cms": { "cms": {
"title": "Páginas CMS", "title": "Páginas CMS",
+5 -1
View File
@@ -1698,7 +1698,11 @@
"enterUrl": "Введите URL...", "enterUrl": "Введите URL...",
"addLink": "Добавить", "addLink": "Добавить",
"cancel": "Отмена" "cancel": "Отмена"
} },
"copiedFromLanguage": "Содержимое скопировано из {{language}}",
"noTranslation": "Перевод отсутствует",
"noTranslationYet": "Перевод для этого языка ещё не существует. Скопируйте из существующего языка:",
"copyFrom": "Копировать из"
}, },
"cms": { "cms": {
"title": "Страницы CMS", "title": "Страницы CMS",
+116 -61
View File
@@ -11,6 +11,7 @@ import {
Eye, Eye,
EyeOff, EyeOff,
ShieldAlert, ShieldAlert,
Copy,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -19,10 +20,18 @@ import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor'; import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
import { Palette } from 'lucide-react'; import { Palette } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service'; import { emailService, type EmailConfig, type EmailTemplate, type EmailTemplateTranslation } from '../../services/email.service';
import { settingsService } from '../../services/settings.service'; import { settingsService } from '../../services/settings.service';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
const SUPPORTED_LANGUAGES = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'nl', name: 'Nederlands', flag: '🇳🇱' },
{ code: 'pt', name: 'Português', flag: '🇧🇷' },
{ code: 'ru', name: 'Русский', flag: '🇷🇺' },
];
const defaultTemplateKeys = [ const defaultTemplateKeys = [
{ {
key: 'gallery_created', key: 'gallery_created',
@@ -92,7 +101,7 @@ export const EmailConfigPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp'); const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp');
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created'); const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({}); const [editedTemplate, setEditedTemplate] = useState<Partial<EmailTemplate>>({});
const [editingLang, setEditingLang] = useState<'en' | 'de'>('en'); const [editingLang, setEditingLang] = useState<string>('en');
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const [testEmail, setTestEmail] = useState(''); const [testEmail, setTestEmail] = useState('');
const [showPreview, setShowPreview] = useState(false); const [showPreview, setShowPreview] = useState(false);
@@ -191,8 +200,8 @@ export const EmailConfigPage: React.FC = () => {
}); });
const saveTemplateMutation = useMutation({ const saveTemplateMutation = useMutation({
mutationFn: ({ key, template }: { key: string; template: Partial<EmailTemplate> }) => mutationFn: ({ key, translations }: { key: string; translations: Record<string, EmailTemplateTranslation> }) =>
emailService.updateTemplate(key, template), emailService.updateTemplate(key, { translations }),
onSuccess: () => { onSuccess: () => {
toast.success(t('toast.saveSuccess')); toast.success(t('toast.saveSuccess'));
queryClient.invalidateQueries({ queryKey: ['email-templates'] }); queryClient.invalidateQueries({ queryKey: ['email-templates'] });
@@ -241,21 +250,41 @@ export const EmailConfigPage: React.FC = () => {
testEmailMutation.mutate(testEmail); testEmailMutation.mutate(testEmail);
}; };
// Get current translation for the editing language
const currentTranslation = editedTemplate.translations?.[editingLang] || { subject: '', body_html: '', body_text: '' };
const handleTranslationChange = (field: keyof EmailTemplateTranslation, value: string) => {
setEditedTemplate(prev => ({
...prev,
translations: {
...prev.translations,
[editingLang]: {
...prev.translations?.[editingLang],
[field]: value,
},
},
}));
};
const handleCopyFromLanguage = (sourceLang: string) => {
const sourceTranslation = editedTemplate.translations?.[sourceLang];
if (!sourceTranslation) return;
setEditedTemplate(prev => ({
...prev,
translations: {
...prev.translations,
[editingLang]: { ...sourceTranslation },
},
}));
toast.info(t('email.copiedFromLanguage', { language: SUPPORTED_LANGUAGES.find(l => l.code === sourceLang)?.name || sourceLang }));
};
const handleSaveTemplate = () => { const handleSaveTemplate = () => {
if (selectedTemplateKey && editedTemplate) { if (selectedTemplateKey && editedTemplate.translations) {
const templateData: Partial<EmailTemplate> = {};
// Include both language versions
if (editedTemplate.subject_en !== undefined) templateData.subject_en = editedTemplate.subject_en;
if (editedTemplate.subject_de !== undefined) templateData.subject_de = editedTemplate.subject_de;
if (editedTemplate.body_html_en !== undefined) templateData.body_html_en = editedTemplate.body_html_en;
if (editedTemplate.body_html_de !== undefined) templateData.body_html_de = editedTemplate.body_html_de;
if (editedTemplate.body_text_en !== undefined) templateData.body_text_en = editedTemplate.body_text_en;
if (editedTemplate.body_text_de !== undefined) templateData.body_text_de = editedTemplate.body_text_de;
saveTemplateMutation.mutate({ saveTemplateMutation.mutate({
key: selectedTemplateKey, key: selectedTemplateKey,
template: templateData translations: editedTemplate.translations,
}); });
} }
}; };
@@ -264,11 +293,10 @@ export const EmailConfigPage: React.FC = () => {
if (!selectedTemplateKey || !editedTemplate) return; if (!selectedTemplateKey || !editedTemplate) return;
// Generate sample data based on the template // Generate sample data based on the template
// Note: These are clearly marked placeholder values for template preview only
const sampleData: Record<string, string> = { const sampleData: Record<string, string> = {
event_name: 'John & Jane Wedding', event_name: 'John & Jane Wedding',
event_date: 'December 25, 2024', event_date: 'December 25, 2024',
password: '••••••••', // Masked placeholder for preview password: '••••••••',
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding', gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
expiration_date: 'January 25, 2025', expiration_date: 'January 25, 2025',
welcome_message: 'Thank you for celebrating our special day with us!', welcome_message: 'Thank you for celebrating our special day with us!',
@@ -290,6 +318,19 @@ export const EmailConfigPage: React.FC = () => {
} }
}; };
// Count how many languages have translations for a template
const getTranslationCount = (template: EmailTemplate) => {
if (!template.translations) return 0;
return Object.keys(template.translations).filter(
lang => template.translations[lang]?.subject || template.translations[lang]?.body_html
).length;
};
// Languages that have content and can be copied from
const copySourceLanguages = SUPPORTED_LANGUAGES.filter(
lang => lang.code !== editingLang && editedTemplate.translations?.[lang.code]?.body_html
);
if (configLoading || templatesLoading) { if (configLoading || templatesLoading) {
return ( return (
<div className="flex items-center justify-center min-h-[400px]"> <div className="flex items-center justify-center min-h-[400px]">
@@ -612,6 +653,8 @@ export const EmailConfigPage: React.FC = () => {
<div className="space-y-2"> <div className="space-y-2">
{templates.map(template => { {templates.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 enTranslation = template.translations?.en;
return ( return (
<button <button
key={template.template_key} key={template.template_key}
@@ -625,11 +668,16 @@ 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' : '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"> <p className="font-medium text-neutral-900 dark:text-neutral-100">
{templateInfo?.name || template.template_key} {templateInfo?.name || template.template_key}
</p> </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>
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate"> <p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
{template.subject_en || template.subject} {enTranslation?.subject || ''}
</p> </p>
</button> </button>
); );
@@ -642,28 +690,6 @@ export const EmailConfigPage: React.FC = () => {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.editTemplate')}</h3> <h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.editTemplate')}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<div className="flex gap-1 mr-4">
<button
onClick={() => setEditingLang('en')}
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'en'
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
>
🇬🇧 English
</button>
<button
onClick={() => setEditingLang('de')}
className={`px-3 py-1 text-sm font-medium rounded-lg transition-colors ${
editingLang === 'de'
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
>
🇩🇪 Deutsch
</button>
</div>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
@@ -684,6 +710,49 @@ export const EmailConfigPage: React.FC = () => {
</div> </div>
</div> </div>
{/* Language tabs */}
<div className="flex flex-wrap gap-1 mb-4 p-1 bg-neutral-100 dark:bg-neutral-700 rounded-lg">
{SUPPORTED_LANGUAGES.map(lang => {
const hasContent = editedTemplate.translations?.[lang.code]?.body_html;
return (
<button
key={lang.code}
onClick={() => setEditingLang(lang.code)}
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors flex items-center gap-1.5 ${
editingLang === lang.code
? 'bg-white dark:bg-neutral-800 text-primary-700 dark:text-primary-300 shadow-sm'
: 'text-neutral-600 dark:text-neutral-400 hover:text-neutral-800 dark:hover:text-neutral-200'
}`}
>
<span>{lang.flag}</span>
<span>{lang.name}</span>
{!hasContent && lang.code !== 'en' && (
<span className="w-1.5 h-1.5 rounded-full bg-amber-400" title={t('email.noTranslation')} />
)}
</button>
);
})}
</div>
{/* Copy from language */}
{!currentTranslation.body_html && copySourceLanguages.length > 0 && (
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p className="text-sm text-blue-800 dark:text-blue-300 mb-2">{t('email.noTranslationYet')}</p>
<div className="flex flex-wrap gap-2">
{copySourceLanguages.map(lang => (
<button
key={lang.code}
onClick={() => handleCopyFromLanguage(lang.code)}
className="inline-flex items-center gap-1.5 px-3 py-1 text-sm bg-white dark:bg-neutral-800 border border-blue-300 dark:border-blue-700 rounded-md hover:bg-blue-50 dark:hover:bg-blue-900/30 text-blue-700 dark:text-blue-300"
>
<Copy className="w-3.5 h-3.5" />
{t('email.copyFrom')} {lang.flag} {lang.name}
</button>
))}
</div>
</div>
)}
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
@@ -699,37 +768,23 @@ export const EmailConfigPage: React.FC = () => {
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('email.subjectLine')} ({editingLang === 'en' ? 'English' : 'German'}) {t('email.subjectLine')} ({SUPPORTED_LANGUAGES.find(l => l.code === editingLang)?.name || editingLang})
</label> </label>
<Input <Input
type="text" type="text"
value={ value={currentTranslation.subject || ''}
editingLang === 'en' onChange={(e) => handleTranslationChange('subject', e.target.value)}
? (editedTemplate.subject_en || editedTemplate.subject || '')
: (editedTemplate.subject_de || '')
}
onChange={(e) => setEditedTemplate(prev => ({
...prev,
[editingLang === 'en' ? 'subject_en' : 'subject_de']: e.target.value
}))}
placeholder="Email subject" placeholder="Email subject"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1"> <label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('email.emailBody')} ({editingLang === 'en' ? 'English' : 'German'}) {t('email.emailBody')} ({SUPPORTED_LANGUAGES.find(l => l.code === editingLang)?.name || editingLang})
</label> </label>
<EmailTemplateEditor <EmailTemplateEditor
content={ content={currentTranslation.body_html || ''}
editingLang === 'en' onChange={(value) => handleTranslationChange('body_html', value)}
? (editedTemplate.body_html_en || editedTemplate.body_html || '')
: (editedTemplate.body_html_de || '')
}
onChange={(value) => setEditedTemplate(prev => ({
...prev,
[editingLang === 'en' ? 'body_html_en' : 'body_html_de']: value
}))}
variables={editedTemplate.variables || []} variables={editedTemplate.variables || []}
/> />
</div> </div>
+10 -12
View File
@@ -11,19 +11,17 @@ export interface EmailConfig {
tls_reject_unauthorized: boolean; tls_reject_unauthorized: boolean;
} }
export interface EmailTemplateTranslation {
subject: string;
body_html: string;
body_text?: string;
}
export interface EmailTemplate { export interface EmailTemplate {
id: number; id: number;
template_key: string; template_key: string;
subject: string; // For backward compatibility
body_html: string; // For backward compatibility
body_text?: string; // For backward compatibility
subject_en: string;
subject_de: string;
body_html_en: string;
body_html_de: string;
body_text_en?: string;
body_text_de?: string;
variables: string[]; variables: string[];
translations: Record<string, EmailTemplateTranslation>;
updated_at: string; updated_at: string;
} }
@@ -63,12 +61,12 @@ export const emailService = {
}, },
// Update email template // Update email template
async updateTemplate(key: string, template: Partial<EmailTemplate>): Promise<void> { async updateTemplate(key: string, data: { translations: Record<string, EmailTemplateTranslation> }): Promise<void> {
await api.put(`/admin/email/templates/${key}`, template); await api.put(`/admin/email/templates/${key}`, data);
}, },
// Preview email template // Preview email template
async previewTemplate(key: string, previewData: Record<string, string>, language: 'en' | 'de' = 'en'): Promise<EmailPreview> { async previewTemplate(key: string, previewData: Record<string, string>, language: string = 'en'): Promise<EmailPreview> {
const response = await api.post<EmailPreview>( const response = await api.post<EmailPreview>(
`/admin/email/templates/${key}/preview`, `/admin/email/templates/${key}/preview`,
{ preview_data: previewData, language } { preview_data: previewData, language }