From 58a67f295133828094f83e9fa6eb8471aa7e9d24 Mon Sep 17 00:00:00 2001 From: Thomas Beaudry Date: Wed, 5 Aug 2026 15:11:48 -0400 Subject: [PATCH 1/5] feat(i18n): translate the remaining interface strings into Spanish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #1442 made Español selectable and translated the namespace JSON files, but left 572 inline `t()` calls naming only `en` and `fr`. libui resolves `obj[resolvedLanguage] ?? obj.en`, so those rendered in English with nothing reporting it — the admin panel and its submenu among them. Adds the missing `es` entry at all 572 sites across apps/web, packages/react-core and apps/gateway, reusing the terminology the completed namespace files already established (Panel de control, Centro de datos, Sujeto, Tarea remota) rather than inventing a second vocabulary. Five strings that carry no words — the `{}: {}` format strings whose `fr` exists only for French's space-before-colon — get an entry identical to English, so the set is complete rather than partly deliberate and partly forgotten. A unit test scans all three frontends and fails on an inline call that names `en` but not `es`, which is what keeps this from decaying; the four AGENTS.md files that taught the `{ en, fr }` shape now teach `{ en, es, fr }` and point at it. apps/playground is untouched: it hard-codes its LanguageToggle options as { en, fr }, so Spanish cannot be selected there at all. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 4 +- apps/gateway/AGENTS.md | 3 +- apps/gateway/src/components/Cap.tsx | 3 + apps/gateway/src/services/axios.ts | 3 + apps/web/AGENTS.md | 9 +- .../src/__tests__/spanish-coverage.test.ts | 99 ++++++++++++ apps/web/src/components/AppErrorComponent.tsx | 6 +- .../AssignmentEmailForm.tsx | 57 +++++-- .../web/src/components/ConnectivityBanner.tsx | 2 + .../EmailTemplateEditor.tsx | 10 +- .../CreateTemplateForm.tsx | 13 +- .../DeleteTemplateDialog.tsx | 5 +- .../EditTemplateDialog.tsx | 4 +- .../GroupEmailTemplates.tsx | 30 +++- .../GroupEmailTemplates/TemplateRow.tsx | 4 +- .../ViewDefaultTemplateDialog.tsx | 9 +- .../GroupSwitcher/GroupSwitcher.tsx | 4 +- .../InstrumentCard/InstrumentCard.tsx | 11 +- .../InstrumentKindDropdown.tsx | 3 + .../LoginPageEditor/LoginPageEditor.tsx | 6 +- .../LoginPageEditor/UnsavedChangesDialog.tsx | 9 +- .../LoginPageEditor/fields/BoldToggle.tsx | 2 +- .../LoginPageEditor/fields/ColorField.tsx | 6 +- .../LoginPageEditor/fields/FontSizeField.tsx | 8 +- .../LoginPageEditor/fields/SectionHeader.tsx | 6 +- .../src/components/LoginPageEditor/hooks.ts | 26 +++- .../preview/FullscreenPreviewDialog.tsx | 7 +- .../LoginPageEditor/preview/PreviewColumn.tsx | 10 +- .../sections/EnableBrandingCard.tsx | 9 +- .../LoginPageEditor/sections/FooterCard.tsx | 8 +- .../sections/LeftPanelThemeCard.tsx | 11 +- .../sections/RightPanelThemeCard.tsx | 11 +- .../LoginPageEditor/sections/SectionCards.tsx | 78 ++++++---- .../sections/TextColorCard.tsx | 11 +- .../MailSettings/MailServerForm.tsx | 36 +++-- .../components/MailSettings/MailSettings.tsx | 20 ++- .../MailSettings/NewUserTemplateSection.tsx | 16 +- .../MailSettings/TestMailSection.tsx | 45 ++++-- apps/web/src/components/Navbar/Navbar.tsx | 2 +- apps/web/src/components/Sidebar/Sidebar.tsx | 11 +- .../StartSessionForm/StartSessionForm.tsx | 2 + .../src/components/UserDropup/UserDropup.tsx | 3 + .../hooks/useCreateInstrumentRepoMutation.ts | 12 +- .../useCreateSeriesInstrumentMutation.ts | 6 +- .../hooks/useDeleteInstrumentRepoMutation.ts | 6 +- .../useDeleteSeriesInstrumentMutation.ts | 6 +- .../src/hooks/useInstrumentVisualization.ts | 3 +- apps/web/src/hooks/useMailErrorMessage.ts | 7 + apps/web/src/hooks/useNavItems.ts | 11 +- .../hooks/useSyncInstrumentRepoMutation.ts | 21 ++- apps/web/src/providers/DisclaimerProvider.tsx | 6 +- .../web/src/providers/WalkthroughProvider.tsx | 40 +++++ apps/web/src/routes/_app/about.tsx | 12 +- .../src/routes/_app/admin/branding/index.tsx | 7 +- .../src/routes/_app/admin/groups/create.tsx | 1 + .../src/routes/_app/admin/groups/index.tsx | 17 ++- .../_app/admin/instrument-repos/index.tsx | 33 +++- apps/web/src/routes/_app/admin/mail.tsx | 2 +- apps/web/src/routes/_app/admin/settings.tsx | 29 +++- .../src/routes/_app/admin/users/create.tsx | 19 ++- .../web/src/routes/_app/admin/users/index.tsx | 31 ++++ apps/web/src/routes/_app/dashboard.tsx | 27 +++- .../routes/_app/datahub/$subjectId/route.tsx | 1 + .../datahub/$subjectId/table/$recordId.tsx | 12 +- apps/web/src/routes/_app/datahub/index.tsx | 28 ++-- .../src/routes/_app/group/email-templates.tsx | 2 +- apps/web/src/routes/_app/group/manage.tsx | 143 +++++++++++++----- .../routes/_app/session/remote-assignment.tsx | 7 + .../src/routes/_app/session/start-session.tsx | 7 +- .../src/routes/_app/upload/$instrumentId.tsx | 22 ++- apps/web/src/routes/_app/upload/index.tsx | 4 + apps/web/src/routes/_app/user.tsx | 10 +- apps/web/src/services/axios.ts | 5 + apps/web/src/utils/validation.ts | 11 +- packages/react-core/AGENTS.md | 10 +- .../src/components/ErrorPage/ErrorPage.tsx | 9 +- .../FileInstrumentContent/Dropzone.tsx | 23 ++- .../FileInstrumentContent.tsx | 2 + .../UploadProgressBar.tsx | 6 +- .../components/FormContent/FormContent.tsx | 1 + .../InstrumentOverview/InstrumentOverview.tsx | 5 +- .../InstrumentRendererContainer.tsx | 3 + .../ScalarInstrumentRenderer.tsx | 3 + .../SeriesInstrumentContent.tsx | 3 + .../SeriesInstrumentRenderer.tsx | 10 +- .../InstrumentSummary/InstrumentSummary.tsx | 17 +++ .../InteractiveContent/InteractiveContent.tsx | 13 +- .../NavigationBlockerDialog.tsx | 4 +- testing/src/pages/_app/admin/settings.page.ts | 6 + testing/src/specs/admin-settings.spec.ts | 20 +++ 90 files changed, 1050 insertions(+), 255 deletions(-) create mode 100644 apps/web/src/__tests__/spanish-coverage.test.ts diff --git a/AGENTS.md b/AGENTS.md index 1259825ee..30b3b13ca 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,9 @@ not every tool loads nested files. - **Every change needs a unit test _and_ an end-to-end test in `testing/`.** See `.agents/docs/playbooks/add-e2e-test.md`. - **All frontend user-facing strings go through `useTranslation`.** Prefer inline - `t({ en: '...', fr: '...' })` unless the string is used more than once. + `t({ en: '...', es: '...', fr: '...' })` unless the string is used more than once. **Every + interface language gets an entry** — a missing one falls back to English silently, so + `apps/web/src/__tests__/spanish-coverage.test.ts` fails the build rather than let it ship. - **Never run the `apps/web` route-tree generator.** `src/route-tree.ts` is generated and git-tracked, but the user regenerates it manually after route changes. Never hand-edit it either. - **If code needs a comment to be understood, the code is wrong** — rewrite it. Comments are for diff --git a/apps/gateway/AGENTS.md b/apps/gateway/AGENTS.md index 94e584259..20bc734d3 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -95,7 +95,8 @@ hydrated tree can disagree with the SSR'd HTML until you do. - The eslint blocks for `apps/web` and `packages/react-core` (no default exports, no bare `clsx`, `jsx-no-literals`) **do not cover this app**, and default exports are in use. Translation is still required, and there are no translation resource files — `src/services/i18n.ts` initializes with - `{}`, so every string is inline `t({ en, fr })`. + `{}`, so every string is inline `t({ en, es, fr })`. Every interface language needs an entry; + `apps/web/src/__tests__/spanish-coverage.test.ts` scans this app and fails on a call missing `es`. - Shared UI comes from `packages/react-core` (`InstrumentRenderer`, `Branding`). Put anything both apps need there, not here. - Validation messages are localized by `src/services/zod.ts`, a thin call to react-core's diff --git a/apps/gateway/src/components/Cap.tsx b/apps/gateway/src/components/Cap.tsx index dbd4917db..bb08c4035 100644 --- a/apps/gateway/src/components/Cap.tsx +++ b/apps/gateway/src/components/Cap.tsx @@ -10,6 +10,7 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve }) const label = t({ en: "I'm a human", + es: 'Soy una persona', fr: 'Je suis un humain' }); @@ -33,10 +34,12 @@ const CapWidget: React.FC<{ onSolve: (token: string) => void }> = ({ onSolve }) data-cap-api-endpoint="/api/auth/" data-cap-i18n-error-label={t({ en: 'Error', + es: 'Error', fr: 'Erreur' })} data-cap-i18n-initial-state={t({ en: "I'm a human", + es: 'Soy una persona', fr: 'Je suis un humain' })} data-cap-i18n-solved-label={label} diff --git a/apps/gateway/src/services/axios.ts b/apps/gateway/src/services/axios.ts index 8fc2f899c..c1df9cea0 100644 --- a/apps/gateway/src/services/axios.ts +++ b/apps/gateway/src/services/axios.ts @@ -7,6 +7,7 @@ axios.interceptors.request.use((config) => { config.timeout = 10000; // abort request after 10 seconds config.timeoutErrorMessage = i18n.t({ en: 'Network Error', + es: 'Error de red', fr: 'Erreur de réseau' }); return config; @@ -20,6 +21,7 @@ axios.interceptors.response.use( notifications.addNotification({ message: i18n.t({ en: 'Unknown Error', + es: 'Error desconocido', fr: 'Erreur inconnue' }), type: 'error' @@ -30,6 +32,7 @@ axios.interceptors.response.use( notifications.addNotification({ message: i18n.t({ en: 'HTTP Request Failed', + es: 'Error en la solicitud HTTP', fr: 'Échec de la requête HTTP' }), title: error.response?.status.toString(), diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 0f02ce427..44e058928 100644 --- a/apps/web/AGENTS.md +++ b/apps/web/AGENTS.md @@ -99,12 +99,17 @@ In components use a selector: `useAppStore((store) => store.currentGroup)`. Outs `useTranslation` comes from `@douglasneuroinformatics/libui/hooks`. Two forms: ```tsx -t({ en: 'Connection Problem', fr: 'Problème de connexion' }); // inline — prefer this +t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' }); // inline — prefer this t('layout.tabs.table'); // keyed, from a namespace JSON ``` +**Every interface language needs an entry.** libui resolves `obj[resolvedLanguage] ?? obj.en`, so a +missing language renders in English and nothing complains — which is why +`src/__tests__/spanish-coverage.test.ts` scans this app, `packages/react-core` and `apps/gateway` +and fails on an inline call that names `en` but not `es`. + Use the keyed form only when a string is reused. Resource files are `src/translations/*.json`, keyed -per-leaf as `{ "en": ..., "fr": ... }` and kept sorted by +per-leaf as `{ "en": ..., "es": ..., "fr": ... }` and kept sorted by `pnpm --filter @opendatacapture/web format:translations`. **Adding a namespace means three edits in `src/services/i18n.ts`** — the import, the `declare module` diff --git a/apps/web/src/__tests__/spanish-coverage.test.ts b/apps/web/src/__tests__/spanish-coverage.test.ts new file mode 100644 index 000000000..643cf9ba6 --- /dev/null +++ b/apps/web/src/__tests__/spanish-coverage.test.ts @@ -0,0 +1,99 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const REPO_ROOT = resolve(import.meta.dirname, '../../../..'); + +/** + * Every frontend that can render Spanish. `apps/web` is the only one of the three with a vitest + * project, so the contract is asserted for all of them from here rather than not at all. + */ +const FRONTEND_ROOTS = ['apps/web/src', 'packages/react-core/src', 'apps/gateway/src']; + +const sourceFiles = (dir: string): string[] => + readdirSync(dir).flatMap((entry) => { + const path = join(dir, entry); + if (statSync(path).isDirectory()) { + return entry === 'node_modules' ? [] : sourceFiles(path); + } + return /\.tsx?$/.test(path) && !/\.(stories|test)\.tsx?$/.test(path) ? [path] : []; + }); + +/** + * The end of the `{…}` starting at `from`. Quotes, template literals and comments are tracked + * because a brace or quote inside one of them is text, not structure. + */ +const endOfObject = (src: string, from: number): number => { + const enclosing: string[] = []; + let depth = 0; + let i = from; + while (i < src.length) { + const char = src[i]!; + const inside = enclosing.at(-1); + if (inside === "'" || inside === '"' || inside === '`') { + if (char === '\\') i++; + else if (char === inside) enclosing.pop(); + else if (inside === '`' && char === '$' && src[i + 1] === '{') { + enclosing.push('${'); + i++; + } + } else if (inside === '//') { + if (char === '\n') enclosing.pop(); + } else if (inside === '/*') { + if (char === '*' && src[i + 1] === '/') { + enclosing.pop(); + i++; + } + } else if (char === "'" || char === '"' || char === '`') { + enclosing.push(char); + } else if (char === '/' && (src[i + 1] === '/' || src[i + 1] === '*')) { + enclosing.push(`/${src[i + 1]}`); + i++; + } else if (char === '{') { + depth++; + } else if (char === '}') { + if (inside === '${') enclosing.pop(); + else if (--depth === 0) return i + 1; + } + i++; + } + return -1; +}; + +const untranslatedCalls = (src: string): string[] => { + const calls: string[] = []; + for (const match of src.matchAll(/\bt\s*\(\s*\{/g)) { + const start = match.index + match[0].length - 1; + const end = endOfObject(src, start); + if (end === -1) continue; + const object = src.slice(start, end); + if (/(^|[{,\s])en\s*:/.test(object) && !/(^|[{,\s])es\s*:/.test(object)) { + calls.push(object.replace(/\s+/g, ' ')); + } + } + return calls; +}; + +describe('Spanish coverage', () => { + it('should give every inline translation a Spanish entry, so selecting Español never falls back to English', () => { + const untranslated = FRONTEND_ROOTS.flatMap((root) => + sourceFiles(join(REPO_ROOT, root)).flatMap((file) => + untranslatedCalls(readFileSync(file, 'utf8')).map( + (call) => `${relative(REPO_ROOT, file)} — ${call.slice(0, 120)}` + ) + ) + ); + expect(untranslated).toStrictEqual([]); + }); + + it('should detect a translation object that names every language but Spanish', () => { + expect(untranslatedCalls(`t({ en: 'Mail', fr: 'Courriel' })`)).toHaveLength(1); + expect(untranslatedCalls(`t({ en: 'Mail', es: 'Correo', fr: 'Courriel' })`)).toHaveLength(0); + }); + + it('should read a brace inside a string as text, so the object it appears in still terminates', () => { + expect(untranslatedCalls(`t({ en: 'a } b', fr: 'c } d' })`)).toHaveLength(1); + expect(untranslatedCalls(`t({ en: \`x \${y} }\`, es: 'z', fr: 'w' })`)).toHaveLength(0); + }); +}); diff --git a/apps/web/src/components/AppErrorComponent.tsx b/apps/web/src/components/AppErrorComponent.tsx index ed9b048c4..cfcc72aa4 100644 --- a/apps/web/src/components/AppErrorComponent.tsx +++ b/apps/web/src/components/AppErrorComponent.tsx @@ -45,22 +45,24 @@ export const AppErrorComponent = ({ error, reset }: ErrorComponentProps) => { )}

- {t({ en: 'Connection Problem', fr: 'Problème de connexion' })} + {t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' })}

{isOnline ? t({ en: "We couldn't reach the server. This is usually temporary — please try again.", + es: 'No se pudo contactar con el servidor. Suele ser algo temporal: vuelva a intentarlo.', fr: 'Impossible de joindre le serveur. Le problème est généralement temporaire — veuillez réessayer.' }) : t({ en: "You appear to be offline. We'll reconnect automatically as soon as your connection returns.", + es: 'Parece que no tiene conexión. Nos reconectaremos automáticamente en cuanto se restablezca.', fr: 'Vous semblez être hors ligne. La reconnexion se fera automatiquement dès le retour de votre connexion.' })}

diff --git a/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx index 71808cf51..0a2e21d8c 100644 --- a/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx +++ b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx @@ -43,7 +43,10 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm // The group's active template sorts first; boolean subtraction is a total order, unlike the // ternary chain it replaces, so the result does not depend on the sort's visiting order. const templateOptions = [ - { label: t({ en: 'Built-in default', fr: 'Modèle par défaut' }), value: DEFAULT_TEMPLATE_OPTION }, + { + label: t({ en: 'Built-in default', es: 'Predeterminada integrada', fr: 'Modèle par défaut' }), + value: DEFAULT_TEMPLATE_OPTION + }, ...templates.map((template) => ({ label: template.name, value: template.id })) ].sort((a, b) => Number(b.value === activeValue) - Number(a.value === activeValue)); @@ -68,7 +71,11 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm setFeedback(null); const fail = (message: string) => { setFeedback({ message, tone: 'error' }); - addNotification({ message, title: t({ en: 'Email failed', fr: 'Échec du courriel' }), type: 'error' }); + addNotification({ + message, + title: t({ en: 'Email failed', es: 'Error al enviar el correo', fr: 'Échec du courriel' }), + type: 'error' + }); }; sendEmailMutation.mutate( { @@ -78,18 +85,33 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm templateId: selectedTemplate === DEFAULT_TEMPLATE_OPTION ? null : selectedTemplate }, { - onError: () => fail(t({ en: 'The email could not be sent', fr: "Le courriel n'a pas pu être envoyé" })), + onError: () => + fail( + t({ + en: 'The email could not be sent', + es: 'No se pudo enviar el correo', + fr: "Le courriel n'a pas pu être envoyé" + }) + ), onSuccess: (result) => { if (result.status !== 'SENT') { fail(mailErrorMessage(result.error)); return; } const message = t( - { en: 'Assignment link sent to {}', fr: "Lien d'évaluation envoyé à {}" }, + { + en: 'Assignment link sent to {}', + es: 'Enlace de la tarea enviado a {}', + fr: "Lien d'évaluation envoyé à {}" + }, { args: [recipient] } ); setFeedback({ message, tone: 'success' }); - addNotification({ message, title: t({ en: 'Email sent', fr: 'Courriel envoyé' }), type: 'success' }); + addNotification({ + message, + title: t({ en: 'Email sent', es: 'Correo enviado', fr: 'Courriel envoyé' }), + type: 'success' + }); setRecipient(''); } } @@ -104,7 +126,9 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm
{templates.length > 0 && (
- + setRecipient(event.target.value)} @@ -171,8 +206,8 @@ export const AssignmentEmailForm = ({ assignment, instrumentLanguages }: Assignm onClick={sendEmail} > {sendEmailMutation.isPending - ? t({ en: 'Sending…', fr: 'Envoi en cours…' }) - : t({ en: 'Email assignment', fr: 'Envoyer par courriel' })} + ? t({ en: 'Sending…', es: 'Enviando…', fr: 'Envoi en cours…' }) + : t({ en: 'Email assignment', es: 'Enviar la tarea por correo', fr: 'Envoyer par courriel' })}
{feedback && ( diff --git a/apps/web/src/components/ConnectivityBanner.tsx b/apps/web/src/components/ConnectivityBanner.tsx index 9b76fec2b..8551c3599 100644 --- a/apps/web/src/components/ConnectivityBanner.tsx +++ b/apps/web/src/components/ConnectivityBanner.tsx @@ -29,10 +29,12 @@ export const ConnectivityBanner = () => { {isOnline ? t({ en: 'Reconnecting…', + es: 'Reconectando…', fr: 'Reconnexion…' }) : t({ en: 'Offline — waiting for connection…', + es: 'Sin conexión: esperando la conexión…', fr: 'Hors ligne — en attente de connexion…' })} diff --git a/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx b/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx index 58cbeb74c..675689e72 100644 --- a/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx +++ b/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx @@ -53,7 +53,7 @@ export const EmailTemplateEditor = ({ return (
- +
- +
- + {!readOnly && variables.length > 0 && (
- {t({ en: 'Insert:', fr: 'Insérer :' })} + + {t({ en: 'Insert:', es: 'Insertar:', fr: 'Insérer :' })} + {variables.map((variable) => { // Placeholder syntax, not copy — it must render verbatim in every language. const tag = `{{${variable}}}`; diff --git a/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx b/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx index 704f18225..f5d49cd72 100644 --- a/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx +++ b/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx @@ -63,9 +63,9 @@ export const CreateTemplateForm = ({ isPending, onCreate, validateContent, valid ref={formRef} onSubmit={handleSubmit} > - {t({ en: 'New template', fr: 'Nouveau modèle' })} + {t({ en: 'New template', es: 'Nueva plantilla', fr: 'Nouveau modèle' })}
- +
@@ -90,12 +90,15 @@ export const CreateTemplateForm = ({ isPending, onCreate, validateContent, valid - {t({ en: 'Missing translations', fr: 'Traductions manquantes' })} + + {t({ en: 'Missing translations', es: 'Faltan traducciones', fr: 'Traductions manquantes' })} + {t( { en: 'Warning: This template is missing translations for: {}.', + es: 'Advertencia: a esta plantilla le faltan traducciones para: {}.', fr: 'Attention : Ce modèle est sans traduction pour : {}.' }, { args: [missingLanguages.map((code) => t(LANGUAGE_LABELS[code])).join(', ')] } @@ -114,7 +117,7 @@ export const CreateTemplateForm = ({ isPending, onCreate, validateContent, valid void create(); }} > - {t({ en: 'Add anyway', fr: 'Ajouter quand même' })} + {t({ en: 'Add anyway', es: 'Agregar de todos modos', fr: 'Ajouter quand même' })} diff --git a/apps/web/src/components/GroupEmailTemplates/DeleteTemplateDialog.tsx b/apps/web/src/components/GroupEmailTemplates/DeleteTemplateDialog.tsx index 4a941d0b9..7051886ac 100644 --- a/apps/web/src/components/GroupEmailTemplates/DeleteTemplateDialog.tsx +++ b/apps/web/src/components/GroupEmailTemplates/DeleteTemplateDialog.tsx @@ -16,12 +16,15 @@ export const DeleteTemplateDialog = ({ isPending, onConfirm, onOpenChange, templ - {t({ en: 'Delete template', fr: 'Supprimer le modèle' })} + + {t({ en: 'Delete template', es: 'Eliminar plantilla', fr: 'Supprimer le modèle' })} + {t( { en: 'Permanently delete "{}"? This cannot be undone.', + es: '¿Eliminar "{}" de forma permanente? Esta acción no se puede deshacer.', fr: 'Supprimer définitivement « {} » ? Cette action est irréversible.' }, { args: [template?.name ?? ''] } diff --git a/apps/web/src/components/GroupEmailTemplates/EditTemplateDialog.tsx b/apps/web/src/components/GroupEmailTemplates/EditTemplateDialog.tsx index c897fb14a..3800b7dab 100644 --- a/apps/web/src/components/GroupEmailTemplates/EditTemplateDialog.tsx +++ b/apps/web/src/components/GroupEmailTemplates/EditTemplateDialog.tsx @@ -51,7 +51,7 @@ const EditTemplateForm = ({ return (
- + - {t({ en: 'Edit template', fr: 'Modifier le modèle' })} + {t({ en: 'Edit template', es: 'Editar plantilla', fr: 'Modifier le modèle' })} {/* Mounted only while open, so the draft resets between templates. */} {template && ( diff --git a/apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx b/apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx index 4cc551594..b5d911281 100644 --- a/apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx +++ b/apps/web/src/components/GroupEmailTemplates/GroupEmailTemplates.tsx @@ -55,6 +55,7 @@ export const GroupEmailTemplates = () => { {t({ en: 'Select a group to manage its email templates.', + es: 'Seleccione un grupo para gestionar sus plantillas de correo.', fr: 'Sélectionnez un groupe pour gérer ses modèles de courriel.' })} @@ -69,12 +70,14 @@ export const GroupEmailTemplates = () => { if (issue === 'incomplete') { return t({ en: 'Fill in the subject and body in each language you have started.', + es: 'Complete el asunto y el cuerpo en cada idioma que haya empezado.', fr: "Remplissez l'objet et le corps dans chaque langue commencée." }); } if (issue === 'missing-vars') { return t({ en: 'The body must include {{url}} and {{expiresAt}}.', + es: 'El cuerpo debe incluir {{url}} y {{expiresAt}}.', fr: 'Le corps doit inclure {{url}} et {{expiresAt}}.' }); } @@ -84,13 +87,17 @@ export const GroupEmailTemplates = () => { const validateName = (candidate: string, exceptId?: string): string | undefined => { const trimmed = candidate.trim(); if (!trimmed) { - return t({ en: 'A name is required', fr: 'Un nom est requis' }); + return t({ en: 'A name is required', es: 'El nombre es obligatorio', fr: 'Un nom est requis' }); } const isDuplicate = templates.some( (template) => template.id !== exceptId && template.name.toLowerCase() === trimmed.toLowerCase() ); return isDuplicate - ? t({ en: 'A template with this name already exists', fr: 'Un modèle avec ce nom existe déjà' }) + ? t({ + en: 'A template with this name already exists', + es: 'Ya existe una plantilla con este nombre', + fr: 'Un modèle avec ce nom existe déjà' + }) : undefined; }; @@ -105,6 +112,7 @@ export const GroupEmailTemplates = () => { addNotification({ message: t({ en: 'The group is still loading — try again in a moment.', + es: 'El grupo aún se está cargando: inténtelo de nuevo en un momento.', fr: 'Le groupe est encore en cours de chargement — réessayez dans un instant.' }), type: 'error' @@ -133,13 +141,15 @@ export const GroupEmailTemplates = () => { message: isConflict ? t({ en: 'Someone else changed these templates while you were editing. Your changes were not saved — reload the page and try again.', + es: 'Otra persona modificó estas plantillas mientras usted editaba. Sus cambios no se guardaron: recargue la página e inténtelo de nuevo.', fr: "Quelqu'un d'autre a modifié ces modèles pendant votre édition. Vos modifications n'ont pas été enregistrées — rechargez la page et réessayez." }) : t({ en: 'Your changes were not saved. Check your connection and try again.', + es: 'Sus cambios no se guardaron. Compruebe su conexión e inténtelo de nuevo.', fr: "Vos modifications n'ont pas été enregistrées. Vérifiez votre connexion et réessayez." }), - title: t({ en: 'Save failed', fr: "Échec de l'enregistrement" }), + title: t({ en: 'Save failed', es: 'Error al guardar', fr: "Échec de l'enregistrement" }), type: 'error' }); if (isConflict) { @@ -167,6 +177,7 @@ export const GroupEmailTemplates = () => { addNotification({ message: t({ en: 'That was the default template, so the built-in message is now used.', + es: 'Esa era la plantilla predeterminada, por lo que ahora se usa el mensaje integrado.', fr: 'Ce modèle était le modèle par défaut ; le message intégré est désormais utilisé.' }), type: 'info' @@ -185,11 +196,16 @@ export const GroupEmailTemplates = () => {
- {t({ en: 'Remote Assignment Templates', fr: "Modèles d'évaluation à distance" })} + {t({ + en: 'Remote Assignment Templates', + es: 'Plantillas de tareas remotas', + fr: "Modèles d'évaluation à distance" + })}

{t({ en: 'Used when emailing a remote assignment link.', + es: 'Se usa al enviar por correo el enlace de una tarea remota.', fr: "Utilisés lors de l'envoi d'un lien d'évaluation à distance." })}

@@ -197,7 +213,7 @@ export const GroupEmailTemplates = () => { { isPending={updateGroupMutation.isPending} label={t({ en: 'Your Open Data Capture Assignment (built-in)', + es: 'Su tarea de Open Data Capture (integrada)', fr: 'Votre évaluation Open Data Capture (intégré)' })} rowId="builtin" @@ -221,7 +238,7 @@ export const GroupEmailTemplates = () => { actions={ )}
diff --git a/apps/web/src/components/GroupEmailTemplates/ViewDefaultTemplateDialog.tsx b/apps/web/src/components/GroupEmailTemplates/ViewDefaultTemplateDialog.tsx index 2cf089b66..a6d2a0e28 100644 --- a/apps/web/src/components/GroupEmailTemplates/ViewDefaultTemplateDialog.tsx +++ b/apps/web/src/components/GroupEmailTemplates/ViewDefaultTemplateDialog.tsx @@ -16,10 +16,17 @@ export const ViewDefaultTemplateDialog = ({ onOpenChange, open }: ViewDefaultTem - {t({ en: 'Built-in default template', fr: 'Modèle par défaut intégré' })} + + {t({ + en: 'Built-in default template', + es: 'Plantilla predeterminada integrada', + fr: 'Modèle par défaut intégré' + })} + {t({ en: 'This is the message sent for remote assignments when no custom template is active.', + es: 'Este es el mensaje que se envía para las tareas remotas cuando no hay ninguna plantilla personalizada activa.', fr: "Il s'agit du message envoyé pour les évaluations à distance lorsqu'aucun modèle personnalisé n'est actif." })} diff --git a/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx b/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx index 933a80827..847b88ac8 100644 --- a/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx +++ b/apps/web/src/components/GroupSwitcher/GroupSwitcher.tsx @@ -38,7 +38,9 @@ export const GroupSwitcher = ({ className }: { className?: string }) => { } const label = ( - {t({ en: 'Group', fr: 'Groupe' })} + + {t({ en: 'Group', es: 'Grupo', fr: 'Groupe' })} + ); // A user in exactly one group has nothing to switch between, so show the group as static text styled diff --git a/apps/web/src/components/InstrumentCard/InstrumentCard.tsx b/apps/web/src/components/InstrumentCard/InstrumentCard.tsx index d7fd3f3cc..8800daa34 100644 --- a/apps/web/src/components/InstrumentCard/InstrumentCard.tsx +++ b/apps/web/src/components/InstrumentCard/InstrumentCard.tsx @@ -32,6 +32,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC kind: 'text', label: t({ en: 'Authors', + es: 'Autores', fr: 'Auteurs' }), text: instrument.details.authors?.join(', ') @@ -40,6 +41,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC kind: 'text', label: t({ en: 'Description', + es: 'Descripción', fr: 'Description' }), text: instrument.details.description @@ -48,6 +50,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC kind: 'text', label: t({ en: 'Edition', + es: 'Edición', fr: 'Édition' }), text: instrument.kind === 'SERIES' ? undefined : instrument.internal.edition.toString() @@ -56,6 +59,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC kind: 'text', label: t({ en: 'Languages', + es: 'Idiomas', fr: 'Langues' }), text: instrument.supportedLanguages @@ -73,6 +77,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC kind: 'text', label: t({ en: 'License', + es: 'Licencia', fr: 'Licence' }), text: license?.name ?? 'NA', @@ -90,10 +95,12 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC {license?.isOpenSource ? t({ en: 'This is a free and open-source license', + es: 'Esta es una licencia libre y de código abierto', fr: "Il s'agit d'une licence libre" }) : t({ en: 'This is not a free and open source license', + es: 'Esta no es una licencia libre y de código abierto', fr: "Il ne s'agit pas d'une licence libre" })}

@@ -106,6 +113,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC kind: 'link', label: t({ en: 'Reference Link', + es: 'Enlace de referencia', fr: 'Lien vers la référence' }) }, @@ -114,6 +122,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC kind: 'link', label: t({ en: 'Source Link', + es: 'Enlace al código fuente', fr: 'Lien vers le code source' }) }, @@ -152,7 +161,7 @@ export const InstrumentCard = ({ highlighted, instrument, onClick }: InstrumentC return (

- {item.label + t({ en: ': ', fr: ' : ' })} + {item.label + t({ en: ': ', es: ': ', fr: ' : ' })} {item.kind === 'text' && {item.text}} {item.kind === 'link' && ( diff --git a/apps/web/src/components/LoginPageEditor/LoginPageEditor.tsx b/apps/web/src/components/LoginPageEditor/LoginPageEditor.tsx index e1038308d..484ea358a 100644 --- a/apps/web/src/components/LoginPageEditor/LoginPageEditor.tsx +++ b/apps/web/src/components/LoginPageEditor/LoginPageEditor.tsx @@ -40,7 +40,11 @@ export const LoginPageEditor = () => {

- {t({ en: 'Customize Login Page', fr: 'Personnaliser la page de connexion' })} + {t({ + en: 'Customize Login Page', + es: 'Personalizar la página de inicio de sesión', + fr: 'Personnaliser la page de connexion' + })} diff --git a/apps/web/src/components/LoginPageEditor/UnsavedChangesDialog.tsx b/apps/web/src/components/LoginPageEditor/UnsavedChangesDialog.tsx index 1dcea0404..81f64776c 100644 --- a/apps/web/src/components/LoginPageEditor/UnsavedChangesDialog.tsx +++ b/apps/web/src/components/LoginPageEditor/UnsavedChangesDialog.tsx @@ -12,20 +12,23 @@ export const UnsavedChangesDialog = ({ blocker }: { blocker: BrandingEditor['blo return ( blocker.reset?.()}> - {t({ en: 'Unsaved Changes', fr: 'Modifications non enregistrées' })} + + {t({ en: 'Unsaved Changes', es: 'Cambios sin guardar', fr: 'Modifications non enregistrées' })} + {t({ en: 'You have unsaved changes. Are you sure you want to leave? Your changes will be lost. Select "No" and click the X in the top-right corner to keep your changes.', + es: 'Tiene cambios sin guardar. ¿Seguro que desea salir? Se perderán sus cambios. Seleccione "No" y haga clic en la X de la esquina superior derecha para conservarlos.', fr: 'Vous avez des modifications non enregistrées. Voulez-vous vraiment quitter ? Vos modifications seront perdues. Sélectionnez « Non » et cliquez sur le X en haut à droite pour conserver vos modifications.' })}
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
diff --git a/apps/web/src/components/LoginPageEditor/fields/BoldToggle.tsx b/apps/web/src/components/LoginPageEditor/fields/BoldToggle.tsx index 77405b7fb..e5f4afbe7 100644 --- a/apps/web/src/components/LoginPageEditor/fields/BoldToggle.tsx +++ b/apps/web/src/components/LoginPageEditor/fields/BoldToggle.tsx @@ -14,7 +14,7 @@ export const BoldToggle = ({ checked, id, onChange }: BoldToggleProps) => {
onChange(c === true)} />
); diff --git a/apps/web/src/components/LoginPageEditor/fields/ColorField.tsx b/apps/web/src/components/LoginPageEditor/fields/ColorField.tsx index 865e24b1b..7467321e3 100644 --- a/apps/web/src/components/LoginPageEditor/fields/ColorField.tsx +++ b/apps/web/src/components/LoginPageEditor/fields/ColorField.tsx @@ -44,7 +44,11 @@ export const ColorField = ({
{isInvalid && (

- {t({ en: 'Enter a valid hex color.', fr: 'Entrez une couleur hexadécimale valide.' })} + {t({ + en: 'Enter a valid hex color.', + es: 'Introduzca un color hexadecimal válido.', + fr: 'Entrez une couleur hexadécimale valide.' + })}

)}
diff --git a/apps/web/src/components/LoginPageEditor/fields/FontSizeField.tsx b/apps/web/src/components/LoginPageEditor/fields/FontSizeField.tsx index 1a53b9d2a..c00dcf79b 100644 --- a/apps/web/src/components/LoginPageEditor/fields/FontSizeField.tsx +++ b/apps/web/src/components/LoginPageEditor/fields/FontSizeField.tsx @@ -15,7 +15,7 @@ export const FontSizeField = ({ id, onChange, value }: FontSizeFieldProps) => { const { t } = useTranslation(); return (
- +