feat(i18n): add Spanish (es) locale (#510)
Contributed by @AloePacci on issue #510. Drops their es.json into the existing locale set, registers Spanish in the language selector with a flag SVG matching the inline style of the other six locales, and extends the email pipeline so es-language guests receive a localised email subject/body where available. Coverage: - frontend/src/i18n/locales/es.json — 2132 translated keys. ~824 EN keys are not yet covered; i18next's `fallbackLng: 'en'` handles those at runtime so the UI never renders a missing key. fr/nl/pt/ru have a similar (smaller) gap and ship the same way. - LanguageSelector.tsx — added the ESFlag inline SVG (red/yellow/red horizontal bands, official #AA151B + #F1BF00; no coat of arms to stay consistent with the other simple flag components) and a new entry in SUPPORTED_LANGUAGES. - emailProcessor.js — added .es to the domain-language heuristic, and an `es:` row to the three inline-translated snippets (passwordSecurityI18n / noPasswordI18n / passwordSetAtCreationI18n). - 106_seed_es_email_template_translations.js (new) — idempotent seeder for the four customer-facing templates AloePacci translated: gallery_created, expiration_warning, gallery_expired, archive_complete. Mirrors the pattern from 099. Template keys without an `es` row fall back to `en` via the existing resolution chain in emailProcessor.processTemplate — no functional gap, just untranslated copy until someone fills them in. What I deliberately did NOT take from the contribution: the proposed in-place edit of migration 075 (history mutation — won't reseed for existing installs anyway) and the whitespace/`gallery_list_html`-drop churn in emailProcessor.js (would have regressed the #354 follow-up). The semantic additions from those files are preserved via 106 and the targeted edits above.
This commit is contained in:
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Migration: seed Spanish (es) email-template translations (#510).
|
||||||
|
*
|
||||||
|
* Contributed by @AloePacci on issue #510. Covers the four
|
||||||
|
* customer-facing gallery delivery templates that already had
|
||||||
|
* en/de/nl/pt/ru rows from migration 075. Templates without an `es`
|
||||||
|
* row (admin_*, backup_*, restore_*, customer_*, version_update_*)
|
||||||
|
* continue to fall back to `en` via the resolution chain in
|
||||||
|
* emailProcessor.processTemplate — no functional gap, just untranslated
|
||||||
|
* copy until someone fills them in.
|
||||||
|
*
|
||||||
|
* Same idempotency pattern as 099_seed_missing_email_template_translations:
|
||||||
|
* checks (template_id, language) before inserting so re-runs are safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TRANSLATIONS = {
|
||||||
|
gallery_created: {
|
||||||
|
es: {
|
||||||
|
subject: 'Su galería de fotos está lista!',
|
||||||
|
body_html: `<h2>Galería creada con éxito</h2>
|
||||||
|
<p>Estimado {{host_name}},</p>
|
||||||
|
<p>Su galería de fotos "{{event_name}}" ha sido creada con éxito!</p>
|
||||||
|
<p><strong>Detalles de la galería:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Fecha del evento: {{event_date}}</li>
|
||||||
|
<li>Enlace de la galería: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
|
||||||
|
<li>Contraseña: {{gallery_password}}</li>
|
||||||
|
<li>Expira en: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Comparta este enlace y contraseña con sus invitados para que puedan ver y descargar las fotos.</p>
|
||||||
|
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
|
||||||
|
body_text: 'Galería creada con éxito\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido creada con éxito!\n\nEnlace de la galería: {{gallery_link}}\nContraseña: {{gallery_password}}\nExpira en: {{expiry_date}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
expiration_warning: {
|
||||||
|
es: {
|
||||||
|
subject: 'Su galería de fotos expirará pronto',
|
||||||
|
body_html: `<h2>Galería expirando pronto</h2>
|
||||||
|
<p>Estimado {{host_name}},</p>
|
||||||
|
<p>Su galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.</p>
|
||||||
|
<p>Después de la expiración, la galería será archivada y ya no estará accesible para los invitados.</p>
|
||||||
|
<p><a href="{{gallery_link}}">Visitar galería</a></p>`,
|
||||||
|
body_text: 'Galería expirando pronto\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.\n\nGalería: {{gallery_link}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
gallery_expired: {
|
||||||
|
es: {
|
||||||
|
subject: 'Su galería de fotos está caducada',
|
||||||
|
body_html: `<h2>Galería vencida</h2>
|
||||||
|
<p>Estimado {{host_name}},</p>
|
||||||
|
<p>Su galería de fotos "{{event_name}}" ha caducado y por tanto ya no es accesible.</p>
|
||||||
|
<p>Las fotos han sido archivadas. Si necesita acceso, por favor póngase en contacto con el administrador a través de {{admin_email}}.</p>`,
|
||||||
|
body_text: 'Galería caducada\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha caducado y ya no está accesible.\n\nContacto: {{admin_email}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
archive_complete: {
|
||||||
|
es: {
|
||||||
|
subject: 'Archivado completado: {{event_name}}',
|
||||||
|
body_html: `<h2>Archivado completado</h2>
|
||||||
|
<p>Estimado {{host_name}},</p>
|
||||||
|
<p>Su galería de fotos "{{event_name}}" ha sido archivada con éxito.</p>
|
||||||
|
<p><strong>Detalles del archivo:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Número de fotos: {{photo_count}}</li>
|
||||||
|
<li>Tamaño del archivo: {{archive_size}}</li>
|
||||||
|
<li>Fecha del archivado: {{archive_date}}</li>
|
||||||
|
</ul>`,
|
||||||
|
body_text: 'Archivado completado\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido archivada con éxito.\n\nFotos: {{photo_count}}\nTamaño: {{archive_size}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.up = async function(knex) {
|
||||||
|
if (!(await knex.schema.hasTable('email_templates'))) return;
|
||||||
|
if (!(await knex.schema.hasTable('email_template_translations'))) return;
|
||||||
|
|
||||||
|
const rows = await knex('email_templates')
|
||||||
|
.whereIn('template_key', Object.keys(TRANSLATIONS))
|
||||||
|
.select('id', 'template_key');
|
||||||
|
const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
|
||||||
|
|
||||||
|
let inserted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
|
||||||
|
const templateId = keyToId[key];
|
||||||
|
if (!templateId) continue;
|
||||||
|
|
||||||
|
for (const [language, content] of Object.entries(perLocale)) {
|
||||||
|
const existing = await knex('email_template_translations')
|
||||||
|
.where({ template_id: templateId, language })
|
||||||
|
.first();
|
||||||
|
if (existing) {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await knex('email_template_translations').insert({
|
||||||
|
template_id: templateId,
|
||||||
|
language,
|
||||||
|
subject: content.subject,
|
||||||
|
body_html: content.body_html,
|
||||||
|
body_text: content.body_text,
|
||||||
|
created_at: new Date(),
|
||||||
|
updated_at: new Date(),
|
||||||
|
});
|
||||||
|
inserted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`106_seed_es_email_template_translations: inserted=${inserted}, skipped=${skipped}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// No-op: same rationale as 099. An admin may have hand-edited the
|
||||||
|
// `es` rows in the Templates UI after this migration ran, and we
|
||||||
|
// can't tell apart inserted-by-us rows from edited-by-admin rows.
|
||||||
|
// Rollback by hand if you truly need to drop them.
|
||||||
|
};
|
||||||
@@ -140,6 +140,7 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
{ domains: ['.nl', '.be'], language: 'nl' },
|
{ domains: ['.nl', '.be'], language: 'nl' },
|
||||||
{ domains: ['.br', '.pt'], language: 'pt' },
|
{ domains: ['.br', '.pt'], language: 'pt' },
|
||||||
{ domains: ['.ru', '.su'], language: 'ru' },
|
{ domains: ['.ru', '.su'], language: 'ru' },
|
||||||
|
{ domains: ['.es'], language: 'es' },
|
||||||
];
|
];
|
||||||
for (const { domains, language: lang } of domainLanguageMap) {
|
for (const { domains, language: lang } of domainLanguageMap) {
|
||||||
if (domains.some(d => domain.endsWith(d))) {
|
if (domains.some(d => domain.endsWith(d))) {
|
||||||
@@ -497,6 +498,7 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
nl: '(Om veiligheidsredenen niet weergegeven)',
|
nl: '(Om veiligheidsredenen niet weergegeven)',
|
||||||
pt: '(Não exibido por motivos de segurança)',
|
pt: '(Não exibido por motivos de segurança)',
|
||||||
ru: '(Не показано в целях безопасности)',
|
ru: '(Не показано в целях безопасности)',
|
||||||
|
es: '(No se muestra por razones de seguridad)',
|
||||||
};
|
};
|
||||||
const noPasswordI18n = {
|
const noPasswordI18n = {
|
||||||
en: 'No password required',
|
en: 'No password required',
|
||||||
@@ -504,6 +506,7 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
nl: 'Geen wachtwoord vereist',
|
nl: 'Geen wachtwoord vereist',
|
||||||
pt: 'Nenhuma senha necessária',
|
pt: 'Nenhuma senha necessária',
|
||||||
ru: 'Пароль не требуется',
|
ru: 'Пароль не требуется',
|
||||||
|
es: 'No se requiere contraseña',
|
||||||
};
|
};
|
||||||
// Sent by the publish-from-draft flow (adminEvents.js): by the time the
|
// Sent by the publish-from-draft flow (adminEvents.js): by the time the
|
||||||
// event is published, only the bcrypt hash is stored, so the plaintext
|
// event is published, only the bcrypt hash is stored, so the plaintext
|
||||||
@@ -515,6 +518,7 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
|
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
|
||||||
pt: 'A senha definida ao criar a galeria',
|
pt: 'A senha definida ao criar a galeria',
|
||||||
ru: 'Пароль, заданный при создании галереи',
|
ru: 'Пароль, заданный при создании галереи',
|
||||||
|
es: 'La contraseña que estableciste al crear la galería',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
if (processedVariables.gallery_password === '{{password_security_message}}') {
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ const FRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
|
|||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ESFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
|
||||||
|
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path fill="#AA151B" d="M0 0h640v120H0z"/>
|
||||||
|
<path fill="#F1BF00" d="M0 120h640v240H0z"/>
|
||||||
|
<path fill="#AA151B" d="M0 360h640v120H0z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|
||||||
export const SUPPORTED_LANGUAGES = [
|
export const SUPPORTED_LANGUAGES = [
|
||||||
{ code: 'en', name: 'English', Flag: GBFlag },
|
{ code: 'en', name: 'English', Flag: GBFlag },
|
||||||
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
|
||||||
@@ -61,6 +69,7 @@ export const SUPPORTED_LANGUAGES = [
|
|||||||
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
|
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
|
||||||
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
|
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
|
||||||
{ code: 'fr', name: 'Français', Flag: FRFlag },
|
{ code: 'fr', name: 'Français', Flag: FRFlag },
|
||||||
|
{ code: 'es', name: 'Español', Flag: ESFlag },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const LanguageSelector: React.FC = () => {
|
export const LanguageSelector: React.FC = () => {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user