diff --git a/backend/migrations/core/075_email_template_translations.js b/backend/migrations/core/075_email_template_translations.js new file mode 100644 index 00000000..0c30d926 --- /dev/null +++ b/backend/migrations/core/075_email_template_translations.js @@ -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: `

Galerij succesvol aangemaakt

+

Beste {{host_name}},

+

Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!

+

Galerij details:

+ +

Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.

+{{#if welcome_message}}

{{welcome_message}}

{{/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: `

Galeria criada com sucesso

+

Prezado(a) {{host_name}},

+

Sua galeria de fotos "{{event_name}}" foi criada com sucesso!

+

Detalhes da galeria:

+ +

Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.

+{{#if welcome_message}}

{{welcome_message}}

{{/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: `

Галерея успешно создана

+

Уважаемый(ая) {{host_name}},

+

Ваша фотогалерея "{{event_name}}" была успешно создана!

+

Детали галереи:

+ +

Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.

+{{#if welcome_message}}

{{welcome_message}}

{{/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: `

Galerij verloopt binnenkort

+

Beste {{host_name}},

+

Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.

+

Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.

+

Galerij bezoeken

`, + 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: `

Galeria expirando em breve

+

Prezado(a) {{host_name}},

+

Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.

+

Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.

+

Visitar galeria

`, + 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: `

Срок действия галереи истекает

+

Уважаемый(ая) {{host_name}},

+

Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.

+

После истечения срока галерея будет архивирована и станет недоступна для гостей.

+

Перейти в галерею

`, + 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: `

Galerij verlopen

+

Beste {{host_name}},

+

Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.

+

De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.

`, + 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: `

Galeria expirada

+

Prezado(a) {{host_name}},

+

Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.

+

As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.

`, + 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: `

Срок действия галереи истёк

+

Уважаемый(ая) {{host_name}},

+

Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.

+

Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.

`, + 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: `

Archivering voltooid

+

Beste {{host_name}},

+

De fotogalerij "{{event_name}}" is succesvol gearchiveerd.

+

Archief details:

+`, + 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: `

Arquivamento concluído

+

Prezado(a) {{host_name}},

+

A galeria de fotos "{{event_name}}" foi arquivada com sucesso.

+

Detalhes do arquivo:

+`, + 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: `

Архивация завершена

+

Уважаемый(ая) {{host_name}},

+

Фотогалерея "{{event_name}}" была успешно архивирована.

+

Детали архива:

+`, + 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'); +}; diff --git a/backend/src/routes/adminEmail.js b/backend/src/routes/adminEmail.js index 6bb29c97..19b236c5 100644 --- a/backend/src/routes/adminEmail.js +++ b/backend/src/routes/adminEmail.js @@ -248,6 +248,58 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res } }); +// Helper: parse variables JSON safely +function parseVariables(template) { + 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 []; + } +} + +// 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 || '', + }; + }); + } 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 { @@ -255,45 +307,17 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req, .select('*') .orderBy('template_key'); - // Parse variables JSON and format for multi-language support - const formattedTemplates = templates.map(template => { - const result = { + 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: (() => { - 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 - }; - - // 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; - }); + variables: parseVariables(template), + translations, + updated_at: template.updated_at, + }); + } res.json(formattedTemplates); } catch (error) { @@ -313,119 +337,99 @@ 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(); - + if (!template) { 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 || ''; - - // 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 || ''; - } - } 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 || ''; + const { translations } = req.body; + + 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 { + 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 } ); @@ -449,16 +453,40 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view' } const { preview_data, language = 'en' } = req.body; - - // Get the appropriate language version - 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 || ''; + + // 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'; + 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])); }); } diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index 2ea88d0a..ed32365e 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -99,15 +99,22 @@ 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; + } } } - + return 'en'; // Default to English } @@ -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 - 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'; + // Get translation from email_template_translations table with fallback chain + let subject = ''; + let htmlBody = ''; + let textBody = ''; - // 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 || ''; + 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'; + 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:', diff --git a/frontend/src/features/settings/tabs/GeneralTab.tsx b/frontend/src/features/settings/tabs/GeneralTab.tsx index 1873d532..2760fe5f 100644 --- a/frontend/src/features/settings/tabs/GeneralTab.tsx +++ b/frontend/src/features/settings/tabs/GeneralTab.tsx @@ -246,6 +246,7 @@ export const GeneralTab: React.FC = ({ > + diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 8011a34c..21252d5c 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 4d596f13..a0dcae8f 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/i18n/locales/nl.json b/frontend/src/i18n/locales/nl.json index d7a925be..4a369ebc 100644 --- a/frontend/src/i18n/locales/nl.json +++ b/frontend/src/i18n/locales/nl.json @@ -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": { diff --git a/frontend/src/i18n/locales/pt.json b/frontend/src/i18n/locales/pt.json index 3c9b9b21..345db31b 100644 --- a/frontend/src/i18n/locales/pt.json +++ b/frontend/src/i18n/locales/pt.json @@ -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", diff --git a/frontend/src/i18n/locales/ru.json b/frontend/src/i18n/locales/ru.json index 2d252858..6fe4f821 100644 --- a/frontend/src/i18n/locales/ru.json +++ b/frontend/src/i18n/locales/ru.json @@ -1698,7 +1698,11 @@ "enterUrl": "Введите URL...", "addLink": "Добавить", "cancel": "Отмена" - } + }, + "copiedFromLanguage": "Содержимое скопировано из {{language}}", + "noTranslation": "Перевод отсутствует", + "noTranslationYet": "Перевод для этого языка ещё не существует. Скопируйте из существующего языка:", + "copyFrom": "Копировать из" }, "cms": { "title": "Страницы CMS", diff --git a/frontend/src/pages/admin/EmailConfigPage.tsx b/frontend/src/pages/admin/EmailConfigPage.tsx index b1c879cd..e1d08698 100644 --- a/frontend/src/pages/admin/EmailConfigPage.tsx +++ b/frontend/src/pages/admin/EmailConfigPage.tsx @@ -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('gallery_created'); const [editedTemplate, setEditedTemplate] = useState>({}); - const [editingLang, setEditingLang] = useState<'en' | 'de'>('en'); + const [editingLang, setEditingLang] = useState('en'); const [showPassword, setShowPassword] = useState(false); const [testEmail, setTestEmail] = useState(''); const [showPreview, setShowPreview] = useState(false); @@ -104,7 +113,7 @@ export const EmailConfigPage: React.FC = () => { const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762'); const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9'); const queryClient = useQueryClient(); - + // SMTP Configuration state const [smtpConfig, setSmtpConfig] = useState({ smtp_host: '', @@ -191,8 +200,8 @@ export const EmailConfigPage: React.FC = () => { }); const saveTemplateMutation = useMutation({ - mutationFn: ({ key, template }: { key: string; template: Partial }) => - emailService.updateTemplate(key, template), + mutationFn: ({ key, translations }: { key: string; translations: Record }) => + 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 = {}; - - // 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({ - key: selectedTemplateKey, - template: templateData + if (selectedTemplateKey && editedTemplate.translations) { + saveTemplateMutation.mutate({ + key: selectedTemplateKey, + 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 = { 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 (
@@ -612,6 +653,8 @@ export const EmailConfigPage: React.FC = () => {
{templates.map(template => { const templateInfo = defaultTemplateKeys.find(t => t.key === template.template_key); + const translationCount = getTranslationCount(template); + const enTranslation = template.translations?.en; return ( ); @@ -642,28 +690,6 @@ export const EmailConfigPage: React.FC = () => {

{t('email.editTemplate')}

-
- - -
+ {/* Language tabs */} +
+ {SUPPORTED_LANGUAGES.map(lang => { + const hasContent = editedTemplate.translations?.[lang.code]?.body_html; + return ( + + ); + })} +
+ + {/* Copy from language */} + {!currentTranslation.body_html && copySourceLanguages.length > 0 && ( +
+

{t('email.noTranslationYet')}

+
+ {copySourceLanguages.map(lang => ( + + ))} +
+
+ )} +
); -}; \ No newline at end of file +}; diff --git a/frontend/src/services/email.service.ts b/frontend/src/services/email.service.ts index e8441f59..dc88b47d 100644 --- a/frontend/src/services/email.service.ts +++ b/frontend/src/services/email.service.ts @@ -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; updated_at: string; } @@ -63,16 +61,16 @@ export const emailService = { }, // Update email template - async updateTemplate(key: string, template: Partial): Promise { - await api.put(`/admin/email/templates/${key}`, template); + async updateTemplate(key: string, data: { translations: Record }): Promise { + await api.put(`/admin/email/templates/${key}`, data); }, // Preview email template - async previewTemplate(key: string, previewData: Record, language: 'en' | 'de' = 'en'): Promise { + async previewTemplate(key: string, previewData: Record, language: string = 'en'): Promise { const response = await api.post( `/admin/email/templates/${key}/preview`, { preview_data: previewData, language } ); return response.data; } -}; \ No newline at end of file +};