Merge pull request #258 from the-luap/feat/email-template-translations
feat: multilingual email templates with translations table
This commit is contained in:
@@ -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
@@ -248,19 +248,8 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
|
||||
}
|
||||
});
|
||||
|
||||
// Get email templates
|
||||
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
|
||||
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: (() => {
|
||||
// Helper: parse variables JSON safely
|
||||
function parseVariables(template) {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
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);
|
||||
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);
|
||||
} catch (error) {
|
||||
@@ -313,75 +337,27 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Handle both old and new schema formats
|
||||
const response = {
|
||||
const translations = await getTemplateTranslations(template.id, template);
|
||||
|
||||
res.json({
|
||||
id: template.id,
|
||||
template_key: template.template_key,
|
||||
variables: (() => {
|
||||
try {
|
||||
if (!template.variables) return [];
|
||||
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);
|
||||
variables: parseVariables(template),
|
||||
translations,
|
||||
updated_at: template.updated_at,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Email template fetch error:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch email template' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update email template
|
||||
// Update email template translations
|
||||
router.put('/templates/:key', [
|
||||
adminAuth,
|
||||
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) => {
|
||||
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')
|
||||
.where('template_key', req.params.key)
|
||||
.first();
|
||||
@@ -390,42 +366,70 @@ router.put('/templates/:key', [
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
}
|
||||
|
||||
// Determine schema type and update accordingly
|
||||
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 || '';
|
||||
const { translations } = req.body;
|
||||
|
||||
// Also update basic columns if they exist
|
||||
if (template.subject !== undefined) {
|
||||
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 || '';
|
||||
if (!translations || typeof translations !== 'object') {
|
||||
return res.status(400).json({ error: 'translations object is required' });
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// Old schema - only update basic columns
|
||||
if (subject_en !== undefined) {
|
||||
updateData.subject = subject_en;
|
||||
updateData.body_html = body_html_en;
|
||||
updateData.body_text = body_text_en || '';
|
||||
await db('email_template_translations').insert({
|
||||
template_id: template.id,
|
||||
language,
|
||||
...row,
|
||||
created_at: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db('email_templates')
|
||||
.where('template_key', req.params.key)
|
||||
.update(updateData);
|
||||
// Update timestamp on parent template
|
||||
await db('email_templates')
|
||||
.where('id', template.id)
|
||||
.update({ updated_at: new Date() });
|
||||
|
||||
if (!updated) {
|
||||
return res.status(404).json({ error: 'Template not found' });
|
||||
// Also sync legacy columns for backward compatibility
|
||||
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
|
||||
await logActivity('email_template_updated',
|
||||
{ template_key: req.params.key },
|
||||
{ template_key: req.params.key, languages: Object.keys(translations) },
|
||||
null,
|
||||
{ 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;
|
||||
|
||||
// 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 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';
|
||||
|
||||
// Handle backward compatibility
|
||||
let htmlContent = template[htmlField] || template.body_html || '';
|
||||
let textContent = template[textField] || template.body_text || '';
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
subject = template[subjectField] || template.subject || '';
|
||||
htmlContent = template[htmlField] || template.body_html || '';
|
||||
textContent = template[textField] || template.body_text || '';
|
||||
}
|
||||
|
||||
if (preview_data) {
|
||||
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 escapedValue = escapeHtml(preview_data[key]);
|
||||
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]));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -99,12 +99,19 @@ async function getRecipientLanguage(email, eventId = null) {
|
||||
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) {
|
||||
const germanDomains = ['.de', '.at', '.ch', '.li'];
|
||||
const domain = email.toLowerCase();
|
||||
if (germanDomains.some(d => domain.endsWith(d))) {
|
||||
return 'de';
|
||||
const domainLanguageMap = [
|
||||
{ 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 { 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 htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
|
||||
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
|
||||
|
||||
// Fall back to non-language-specific fields for backward compatibility
|
||||
let subject = template[subjectField] || template.subject || '';
|
||||
let htmlBody = template[htmlField] || template.body_html || '';
|
||||
let textBody = template[textField] || template.body_text || '';
|
||||
subject = template[subjectField] || template.subject_en || template.subject || '';
|
||||
htmlBody = template[htmlField] || template.body_html_en || template.body_html || '';
|
||||
textBody = template[textField] || template.body_text_en || template.body_text || '';
|
||||
}
|
||||
|
||||
// Process variables before template compilation
|
||||
const processedVariables = { ...variables };
|
||||
|
||||
// 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}}') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? '(Aus Sicherheitsgründen nicht angezeigt)'
|
||||
: '(Not shown for security reasons)';
|
||||
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
|
||||
}
|
||||
|
||||
if (processedVariables.gallery_password === 'No password required') {
|
||||
processedVariables.gallery_password = language === 'de'
|
||||
? 'Kein Passwort erforderlich'
|
||||
: 'No password required';
|
||||
processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
|
||||
}
|
||||
|
||||
// Format dates if they exist
|
||||
@@ -371,6 +421,12 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
link: 'Открыть доступ клиента',
|
||||
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: {
|
||||
label: 'Acesso do Cliente (Privado)',
|
||||
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="de">Deutsch</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pt">Português (Brasil)</option>
|
||||
<option value="ru">Русский</option>
|
||||
</select>
|
||||
|
||||
@@ -2149,7 +2149,11 @@
|
||||
"enterUrl": "URL eingeben...",
|
||||
"addLink": "Hinzufügen",
|
||||
"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": {
|
||||
"title": "CMS-Seiten",
|
||||
|
||||
@@ -1698,7 +1698,11 @@
|
||||
"enterUrl": "Enter URL...",
|
||||
"addLink": "Add",
|
||||
"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": {
|
||||
"title": "CMS Pages",
|
||||
|
||||
@@ -433,7 +433,7 @@
|
||||
"loadingPhotos": "Foto's laden...",
|
||||
"photoCategories": "Fotocategorieen",
|
||||
"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",
|
||||
"hostEmailHelp": "Klant ontvangt meldingen over aanmaak en verloop van de galerij",
|
||||
"adminEmailHelp": "Ontvangt systeemmeldingen en archiefbevestigingen",
|
||||
@@ -569,19 +569,13 @@
|
||||
"guestsCannotAccessGallery": "Gasten hebben geen toegang meer tot de galerij. Overweeg dit evenement te archiveren.",
|
||||
"warningEmailsHaveBeenSent": "Waarschuwingsmails zijn naar de klant verzonden.",
|
||||
"extendSevenDays": "7 dagen verlengen",
|
||||
"eventInformation": "Evenementinformatie",
|
||||
"welcomeMessageLabel": "Welkomstbericht",
|
||||
"noWelcomeMessageSet": "Geen welkomstbericht ingesteld",
|
||||
"hostEmail": "E-mail klant",
|
||||
"adminEmail": "E-mail beheerder",
|
||||
"createdOn": "Aangemaakt",
|
||||
"shareLink": "Deellink",
|
||||
"copy": "Kopieren",
|
||||
"copied": "Gekopieerd!",
|
||||
"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",
|
||||
"archivedOn": "Gearchiveerd op",
|
||||
"downloadingArchive": "Archief {{name}} downloaden...",
|
||||
"downloadStarted": "Download gestart",
|
||||
"failedToDownloadArchive": "Kan archief niet downloaden",
|
||||
@@ -1704,7 +1698,11 @@
|
||||
"enterUrl": "Voer URL in...",
|
||||
"addLink": "Toevoegen",
|
||||
"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": {
|
||||
"title": "CMS-pagina's",
|
||||
@@ -2003,7 +2001,15 @@
|
||||
"duration": "Duur",
|
||||
"actions": "Acties"
|
||||
},
|
||||
"details": "Details",
|
||||
"details": {
|
||||
"backupDetails": "Back-updetails",
|
||||
"destination": "Bestemming",
|
||||
"started": "Gestart",
|
||||
"completed": "Voltooid",
|
||||
"contentBackedUp": "Geback-upte inhoud",
|
||||
"errorDetails": "Foutdetails",
|
||||
"manifest": "Manifest"
|
||||
},
|
||||
"statistics": "Statistieken",
|
||||
"errors": "Fouten",
|
||||
"backupDetails": {
|
||||
@@ -2033,15 +2039,6 @@
|
||||
"backupsWillAppear": "Back-ups verschijnen hier zodra ze zijn aangemaakt",
|
||||
"messages": {
|
||||
"deleteSuccess": "Back-up succesvol verwijderd"
|
||||
},
|
||||
"details": {
|
||||
"backupDetails": "Back-updetails",
|
||||
"destination": "Bestemming",
|
||||
"started": "Gestart",
|
||||
"completed": "Voltooid",
|
||||
"contentBackedUp": "Geback-upte inhoud",
|
||||
"errorDetails": "Foutdetails",
|
||||
"manifest": "Manifest"
|
||||
}
|
||||
},
|
||||
"restore": {
|
||||
|
||||
@@ -1698,7 +1698,11 @@
|
||||
"enterUrl": "Digite a URL...",
|
||||
"addLink": "Adicionar",
|
||||
"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": {
|
||||
"title": "Páginas CMS",
|
||||
|
||||
@@ -1698,7 +1698,11 @@
|
||||
"enterUrl": "Введите URL...",
|
||||
"addLink": "Добавить",
|
||||
"cancel": "Отмена"
|
||||
}
|
||||
},
|
||||
"copiedFromLanguage": "Содержимое скопировано из {{language}}",
|
||||
"noTranslation": "Перевод отсутствует",
|
||||
"noTranslationYet": "Перевод для этого языка ещё не существует. Скопируйте из существующего языка:",
|
||||
"copyFrom": "Копировать из"
|
||||
},
|
||||
"cms": {
|
||||
"title": "Страницы CMS",
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Eye,
|
||||
EyeOff,
|
||||
ShieldAlert,
|
||||
Copy,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -19,10 +20,18 @@ import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
|
||||
import { Palette } from 'lucide-react';
|
||||
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 { 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 = [
|
||||
{
|
||||
key: 'gallery_created',
|
||||
@@ -92,7 +101,7 @@ export const EmailConfigPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'smtp' | 'templates'>('smtp');
|
||||
const [selectedTemplateKey, setSelectedTemplateKey] = useState<string>('gallery_created');
|
||||
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 [testEmail, setTestEmail] = useState('');
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
@@ -191,8 +200,8 @@ export const EmailConfigPage: React.FC = () => {
|
||||
});
|
||||
|
||||
const saveTemplateMutation = useMutation({
|
||||
mutationFn: ({ key, template }: { key: string; template: Partial<EmailTemplate> }) =>
|
||||
emailService.updateTemplate(key, template),
|
||||
mutationFn: ({ key, translations }: { key: string; translations: Record<string, EmailTemplateTranslation> }) =>
|
||||
emailService.updateTemplate(key, { translations }),
|
||||
onSuccess: () => {
|
||||
toast.success(t('toast.saveSuccess'));
|
||||
queryClient.invalidateQueries({ queryKey: ['email-templates'] });
|
||||
@@ -241,21 +250,41 @@ export const EmailConfigPage: React.FC = () => {
|
||||
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 = () => {
|
||||
if (selectedTemplateKey && editedTemplate) {
|
||||
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;
|
||||
|
||||
if (selectedTemplateKey && editedTemplate.translations) {
|
||||
saveTemplateMutation.mutate({
|
||||
key: selectedTemplateKey,
|
||||
template: templateData
|
||||
translations: editedTemplate.translations,
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -264,11 +293,10 @@ export const EmailConfigPage: React.FC = () => {
|
||||
if (!selectedTemplateKey || !editedTemplate) return;
|
||||
|
||||
// Generate sample data based on the template
|
||||
// Note: These are clearly marked placeholder values for template preview only
|
||||
const sampleData: Record<string, string> = {
|
||||
event_name: 'John & Jane Wedding',
|
||||
event_date: 'December 25, 2024',
|
||||
password: '••••••••', // Masked placeholder for preview
|
||||
password: '••••••••',
|
||||
gallery_link: 'https://photos.example.com/gallery/john-jane-wedding',
|
||||
expiration_date: 'January 25, 2025',
|
||||
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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
@@ -612,6 +653,8 @@ export const EmailConfigPage: React.FC = () => {
|
||||
<div className="space-y-2">
|
||||
{templates.map(template => {
|
||||
const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key);
|
||||
const translationCount = getTranslationCount(template);
|
||||
const enTranslation = template.translations?.en;
|
||||
return (
|
||||
<button
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{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>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1 truncate">
|
||||
{template.subject_en || template.subject}
|
||||
{enTranslation?.subject || ''}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
@@ -642,28 +690,6 @@ export const EmailConfigPage: React.FC = () => {
|
||||
<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>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -684,6 +710,49 @@ export const EmailConfigPage: React.FC = () => {
|
||||
</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>
|
||||
<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>
|
||||
<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>
|
||||
<Input
|
||||
type="text"
|
||||
value={
|
||||
editingLang === 'en'
|
||||
? (editedTemplate.subject_en || editedTemplate.subject || '')
|
||||
: (editedTemplate.subject_de || '')
|
||||
}
|
||||
onChange={(e) => setEditedTemplate(prev => ({
|
||||
...prev,
|
||||
[editingLang === 'en' ? 'subject_en' : 'subject_de']: e.target.value
|
||||
}))}
|
||||
value={currentTranslation.subject || ''}
|
||||
onChange={(e) => handleTranslationChange('subject', e.target.value)}
|
||||
placeholder="Email subject"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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>
|
||||
<EmailTemplateEditor
|
||||
content={
|
||||
editingLang === 'en'
|
||||
? (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
|
||||
}))}
|
||||
content={currentTranslation.body_html || ''}
|
||||
onChange={(value) => handleTranslationChange('body_html', value)}
|
||||
variables={editedTemplate.variables || []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -11,19 +11,17 @@ export interface EmailConfig {
|
||||
tls_reject_unauthorized: boolean;
|
||||
}
|
||||
|
||||
export interface EmailTemplateTranslation {
|
||||
subject: string;
|
||||
body_html: string;
|
||||
body_text?: string;
|
||||
}
|
||||
|
||||
export interface EmailTemplate {
|
||||
id: number;
|
||||
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[];
|
||||
translations: Record<string, EmailTemplateTranslation>;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
@@ -63,12 +61,12 @@ export const emailService = {
|
||||
},
|
||||
|
||||
// Update email template
|
||||
async updateTemplate(key: string, template: Partial<EmailTemplate>): Promise<void> {
|
||||
await api.put(`/admin/email/templates/${key}`, template);
|
||||
async updateTemplate(key: string, data: { translations: Record<string, EmailTemplateTranslation> }): Promise<void> {
|
||||
await api.put(`/admin/email/templates/${key}`, data);
|
||||
},
|
||||
|
||||
// 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>(
|
||||
`/admin/email/templates/${key}/preview`,
|
||||
{ preview_data: previewData, language }
|
||||
|
||||
Reference in New Issue
Block a user