diff --git a/.agents/docs/packages/libui.md b/.agents/docs/packages/libui.md index f0cb6b579..40e6719e3 100644 --- a/.agents/docs/packages/libui.md +++ b/.agents/docs/packages/libui.md @@ -30,7 +30,7 @@ There is no root `.` export — always import from a subpath. ### Translation — this is a hard rule -Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file: +Every user-facing string must go through `useTranslation`. Prefer an inline `t({ en, es, fr })` unless the string is used in more than one place, in which case add it to a namespaced translation JSON file: ```tsx import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; @@ -38,9 +38,15 @@ import { useTranslation } from '@douglasneuroinformatics/libui/hooks'; const { t } = useTranslation(); // inline strings const { t } = useTranslation('datahub'); // scoped to a translation namespace -; +; ``` +**Every interface language needs an entry.** A missing one resolves to English silently, so +`requireCompleteTranslations` is declared for `apps/web`, `apps/gateway` and `packages/react-core` +(`packages/react-core/src/complete-translations.d.ts` and each app's `src/services/i18n.ts`), which +makes an incomplete `t({ … })` a type error caught by `pnpm lint`. `apps/playground` is deliberately +outside that opt-in — its language selector offers only English and French. + `useTranslation` also returns `resolvedLanguage` when you need the active locale. ### Typing i18n to this app (`apps/web/src/services/i18n.ts`) diff --git a/.agents/docs/playbooks/add-web-data-hook.md b/.agents/docs/playbooks/add-web-data-hook.md index d8ad5755e..562c42508 100644 --- a/.agents/docs/playbooks/add-web-data-hook.md +++ b/.agents/docs/playbooks/add-web-data-hook.md @@ -54,7 +54,7 @@ step type-checks and compiles, and fails at runtime or serves the wrong cached d (`throwOnError: true` for both queries and mutations, in `apps/web/src/services/react-query.ts`, which routes errors to the route error boundary). Set `throwOnError: false` on the mutation _and_ `meta: { disableDefaultErrorNotification: true }` on the request, then notify in `onError` with - `getApiErrorMessage(err, t({ en: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows + `getApiErrorMessage(err, t({ en: …, es: …, fr: … }))` from `@/utils/error`. Omitting the meta flag shows two toasts. Canonical: `apps/web/src/hooks/useDeleteSeriesInstrumentMutation.ts`. 11. **Write the unit test in `apps/web/src/hooks/__tests__/.test.ts` — never under diff --git a/.agents/docs/playbooks/promote-to-react-core.md b/.agents/docs/playbooks/promote-to-react-core.md index 9eed57a72..8a3222055 100644 --- a/.agents/docs/playbooks/promote-to-react-core.md +++ b/.agents/docs/playbooks/promote-to-react-core.md @@ -38,7 +38,7 @@ so nothing that passed lint in `apps/web` fails on style here; what breaks is re the component is an internal part of `InstrumentRenderer` rather than something a consumer imports directly, leave it off the barrel and add it to that AGENTS.md's deliberately-unexported list. -4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', fr: '…' })`** — libui's own +4. **Convert every keyed `t('namespace.key')` to inline `t({ en: '…', es: '…', fr: '…' })`** — libui's own registered namespace is the exception (`.agents/skills/odc-frontend/SKILL.md`). `apps/gateway` initialises i18n with no resources (`apps/gateway/src/services/i18n.ts`), and libui's `Translator.t()` logs `Failed to extract translation from object '{}'` and returns the diff --git a/AGENTS.md b/AGENTS.md index 1259825ee..e0c7926b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,10 @@ not every tool loads nested files. version. `minimumReleaseAge` is 7 days, so a freshly published package will be rejected. - **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. +- **All frontend user-facing strings go through `useTranslation`.** Every language the frontend + exposes gets an entry. In web, gateway and react-core, prefer inline + `t({ en: '...', es: '...', fr: '...' })` unless the string is used more than once; each workspace + enables `requireCompleteTranslations`, so omitting a language is a type error caught by `pnpm lint`. - **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..41f091f56 100644 --- a/apps/gateway/AGENTS.md +++ b/apps/gateway/AGENTS.md @@ -95,7 +95,9 @@ 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; + `requireCompleteTranslations` in that service makes a missing one a type error caught by + `pnpm lint`. - 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/gateway/src/services/i18n.ts b/apps/gateway/src/services/i18n.ts index c32af3fc4..82098fa06 100644 --- a/apps/gateway/src/services/i18n.ts +++ b/apps/gateway/src/services/i18n.ts @@ -1,5 +1,16 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +/* eslint-disable @typescript-eslint/no-namespace */ + import { i18n } from '@douglasneuroinformatics/libui/i18n'; +declare module '@douglasneuroinformatics/libui/i18n' { + export namespace UserConfig { + export interface Options { + requireCompleteTranslations: true; + } + } +} + // `defaultLanguage` is deliberately left unset. It is what `t()` falls back to when a string has no // entry in the resolved language, so pointing it at the patient's language would make every string // missing a translation render as nothing. The session's language is set with `changeLanguage`. diff --git a/apps/web/AGENTS.md b/apps/web/AGENTS.md index 0f02ce427..ad70a23da 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 +`requireCompleteTranslations` is set in `src/services/i18n.ts`: omitting a language from an inline +`t()` call is a type error caught by `pnpm lint`. + 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/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/constants.ts b/apps/web/src/components/LoginPageEditor/constants.ts index d422f84e1..5f10fbf56 100644 --- a/apps/web/src/components/LoginPageEditor/constants.ts +++ b/apps/web/src/components/LoginPageEditor/constants.ts @@ -17,48 +17,48 @@ export const RIGHT_PANEL_OPTIONS = [ ] as const; export type RightPanelOption = (typeof RIGHT_PANEL_OPTIONS)[number]; -export const RIGHT_PANEL_LABELS: { [K in RightPanelOption]: { en: string; fr: string } } = { - custom: { en: 'Custom', fr: 'Personnalisé' }, - forest: { en: 'Forest', fr: 'Forêt' }, - midnight: { en: 'Midnight', fr: 'Minuit' }, - none: { en: 'Default', fr: 'Par défaut' }, - ocean: { en: 'Ocean', fr: 'Océan' }, - rose: { en: 'Rose', fr: 'Rose' }, - slate: { en: 'Slate', fr: 'Ardoise' }, - violet: { en: 'Violet', fr: 'Violet' } +export const RIGHT_PANEL_LABELS: { [K in RightPanelOption]: { en: string; es: string; fr: string } } = { + custom: { en: 'Custom', es: 'Personalizado', fr: 'Personnalisé' }, + forest: { en: 'Forest', es: 'Bosque', fr: 'Forêt' }, + midnight: { en: 'Midnight', es: 'Medianoche', fr: 'Minuit' }, + none: { en: 'Default', es: 'Predeterminado', fr: 'Par défaut' }, + ocean: { en: 'Ocean', es: 'Océano', fr: 'Océan' }, + rose: { en: 'Rose', es: 'Rosa', fr: 'Rose' }, + slate: { en: 'Slate', es: 'Pizarra', fr: 'Ardoise' }, + violet: { en: 'Violet', es: 'Violeta', fr: 'Violet' } }; -export const THEME_LABELS: { [K in LoginTheme]: { en: string; fr: string } } = { - custom: { en: 'Custom', fr: 'Personnalisé' }, - forest: { en: 'Forest', fr: 'Forêt' }, - midnight: { en: 'Midnight', fr: 'Minuit' }, - ocean: { en: 'Ocean', fr: 'Océan' }, - rose: { en: 'Rose', fr: 'Rose' }, - slate: { en: 'Slate', fr: 'Ardoise' }, - sunset: { en: 'Sunset', fr: 'Coucher de soleil' }, - violet: { en: 'Violet', fr: 'Violet' } +export const THEME_LABELS: { [K in LoginTheme]: { en: string; es: string; fr: string } } = { + custom: { en: 'Custom', es: 'Personalizado', fr: 'Personnalisé' }, + forest: { en: 'Forest', es: 'Bosque', fr: 'Forêt' }, + midnight: { en: 'Midnight', es: 'Medianoche', fr: 'Minuit' }, + ocean: { en: 'Ocean', es: 'Océano', fr: 'Océan' }, + rose: { en: 'Rose', es: 'Rosa', fr: 'Rose' }, + slate: { en: 'Slate', es: 'Pizarra', fr: 'Ardoise' }, + sunset: { en: 'Sunset', es: 'Atardecer', fr: 'Coucher de soleil' }, + violet: { en: 'Violet', es: 'Violeta', fr: 'Violet' } }; -export const LOGO_SIZE_LABELS: { [K in LogoSize]: { en: string; fr: string } } = { - custom: { en: 'Custom', fr: 'Personnalisé' }, - large: { en: 'Large', fr: 'Grand' }, - medium: { en: 'Medium', fr: 'Moyen' }, - small: { en: 'Small', fr: 'Petit' }, - xlarge: { en: 'Extra Large', fr: 'Très grand' } +export const LOGO_SIZE_LABELS: { [K in LogoSize]: { en: string; es: string; fr: string } } = { + custom: { en: 'Custom', es: 'Personalizado', fr: 'Personnalisé' }, + large: { en: 'Large', es: 'Grande', fr: 'Grand' }, + medium: { en: 'Medium', es: 'Mediano', fr: 'Moyen' }, + small: { en: 'Small', es: 'Pequeño', fr: 'Petit' }, + xlarge: { en: 'Extra Large', es: 'Extra grande', fr: 'Très grand' } }; -export const LOGO_ALIGNMENT_LABELS: { [K in LogoAlignment]: { en: string; fr: string } } = { - center: { en: 'Center', fr: 'Centre' }, - left: { en: 'Left', fr: 'Gauche' }, - right: { en: 'Right', fr: 'Droite' } +export const LOGO_ALIGNMENT_LABELS: { [K in LogoAlignment]: { en: string; es: string; fr: string } } = { + center: { en: 'Center', es: 'Centro', fr: 'Centre' }, + left: { en: 'Left', es: 'Izquierda', fr: 'Gauche' }, + right: { en: 'Right', es: 'Derecha', fr: 'Droite' } }; -export const SECTION_TITLES: { [K in PanelSection]: { en: string; fr: string } } = { - details: { en: 'Details', fr: 'Détails' }, - logo: { en: 'Login Image', fr: 'Image de connexion' }, - name: { en: 'Instance Name', fr: "Nom de l'instance" }, - resources: { en: 'Resources', fr: 'Ressources' }, - tagline: { en: 'Main Description', fr: 'Description principale' } +export const SECTION_TITLES: { [K in PanelSection]: { en: string; es: string; fr: string } } = { + details: { en: 'Details', es: 'Detalles', fr: 'Détails' }, + logo: { en: 'Login Image', es: 'Imagen de inicio de sesión', fr: 'Image de connexion' }, + name: { en: 'Instance Name', es: 'Nombre de la instancia', fr: "Nom de l'instance" }, + resources: { en: 'Resources', es: 'Recursos', fr: 'Ressources' }, + tagline: { en: 'Main Description', es: 'Descripción principal', fr: 'Description principale' } }; export const DEFAULT_SECTIONS_ORDER: PanelSection[] = ['logo', 'name', 'tagline', 'details', 'resources']; 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 (
- +