diff --git a/apps/api/src/setup/dto/update-setup-state.dto.ts b/apps/api/src/setup/dto/update-setup-state.dto.ts index 233e89429..3bf53a731 100644 --- a/apps/api/src/setup/dto/update-setup-state.dto.ts +++ b/apps/api/src/setup/dto/update-setup-state.dto.ts @@ -5,6 +5,9 @@ import type { BrandingConfig, UpdateSetupStateData } from '@opendatacapture/sche @ValidationSchema($UpdateSetupStateData) export class UpdateSetupStateDto implements UpdateSetupStateData { + @ApiProperty({ required: false }) + activeLanguages?: string[]; + @ApiProperty({ required: false }) branding?: BrandingConfig | null; diff --git a/apps/gateway/src/Root.tsx b/apps/gateway/src/Root.tsx index 27379ef21..33530981c 100644 --- a/apps/gateway/src/Root.tsx +++ b/apps/gateway/src/Root.tsx @@ -1,7 +1,8 @@ import { useEffect, useRef, useState } from 'react'; import { LanguageToggle, ThemeToggle } from '@douglasneuroinformatics/libui/components'; -import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks'; +import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks'; +import type { Language } from '@douglasneuroinformatics/libui/i18n'; import { CoreProvider } from '@douglasneuroinformatics/libui/providers'; import { Branding, InstrumentRenderer } from '@opendatacapture/react-core'; import type { InstrumentSubmitHandler } from '@opendatacapture/react-core'; @@ -14,21 +15,30 @@ import CapWidget from './components/Cap'; import './services/axios'; import './services/i18n'; +const GATEWAY_LANGUAGES: { [key: string]: string } = { en: 'English', fr: 'Français' }; +const VALID_LANGUAGES = ['en', 'es', 'fr']; + export type RootProps = { id: string; initialSeriesIndex?: number; + lang?: string; target: InstrumentBundleContainer; token: string; }; -export const Root = ({ id, initialSeriesIndex, target, token }: RootProps) => { +export const Root = ({ id, initialSeriesIndex, lang, target, token }: RootProps) => { const ref = useRef(null); const notifications = useNotificationsStore(); + const { changeLanguage } = useTranslation(); const [verified, setVerified] = useState(false); useEffect(() => { ref.current!.style.display = 'flex'; + const targetLang = lang ?? new URLSearchParams(window.location.search).get('lang') ?? undefined; + if (targetLang && VALID_LANGUAGES.includes(targetLang)) { + changeLanguage(targetLang as Language); + } }, []); // Solving the Cap challenge redeems a short-lived token; exchange it immediately for a @@ -79,13 +89,7 @@ export const Root = ({ id, initialSeriesIndex, target, token }: RootProps) => {
- +
diff --git a/apps/gateway/src/entry-client.tsx b/apps/gateway/src/entry-client.tsx index 618545709..e2d947b4d 100644 --- a/apps/gateway/src/entry-client.tsx +++ b/apps/gateway/src/entry-client.tsx @@ -1,6 +1,9 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { Language } from '@douglasneuroinformatics/libui/i18n'; + import { Root } from './Root'; import '@opendatacapture/react-core/globals.css'; @@ -8,6 +11,11 @@ import './globals.css'; const ROOT_PROPS = window.__ROOT_PROPS__; +const lang = ROOT_PROPS.lang ?? new URLSearchParams(window.location.search).get('lang') ?? undefined; +if (lang && ['en', 'es', 'fr'].includes(lang)) { + i18n.changeLanguage(lang as Language); +} + ReactDOM.hydrateRoot( document.getElementById('root')!, diff --git a/apps/gateway/src/entry-server.tsx b/apps/gateway/src/entry-server.tsx index c3949f529..a4a0d523f 100644 --- a/apps/gateway/src/entry-server.tsx +++ b/apps/gateway/src/entry-server.tsx @@ -1,6 +1,9 @@ import React from 'react'; import ReactDOMServer from 'react-dom/server'; +import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { Language } from '@douglasneuroinformatics/libui/i18n'; + import { Root } from './Root'; import type { RootProps } from './Root'; @@ -8,6 +11,9 @@ import type { RootProps } from './Root'; export type RenderFunction = (props: RootProps) => { html: string }; export const render: RenderFunction = (props) => { + if (props.lang && ['en', 'es', 'fr'].includes(props.lang)) { + i18n.changeLanguage(props.lang as Language); + } const html = ReactDOMServer.renderToString( diff --git a/apps/gateway/src/routers/root.router.ts b/apps/gateway/src/routers/root.router.ts index 858d63cfa..4f67ffdff 100644 --- a/apps/gateway/src/routers/root.router.ts +++ b/apps/gateway/src/routers/root.router.ts @@ -41,9 +41,11 @@ router.get( } const token = generateToken(assignment.id); + const lang = typeof req.query.lang === 'string' ? req.query.lang : undefined; const html = res.locals.loadRoot({ id, initialSeriesIndex, + lang, target: targetParseResult.data, token } satisfies RootProps); diff --git a/apps/gateway/src/services/i18n.ts b/apps/gateway/src/services/i18n.ts index 48cd67600..40ea69ca9 100644 --- a/apps/gateway/src/services/i18n.ts +++ b/apps/gateway/src/services/i18n.ts @@ -1,5 +1,32 @@ +/* eslint-disable @typescript-eslint/consistent-type-definitions */ +/* eslint-disable @typescript-eslint/no-namespace */ + import { i18n } from '@douglasneuroinformatics/libui/i18n'; +import type { Language } from '@douglasneuroinformatics/libui/i18n'; + +declare module '@douglasneuroinformatics/libui/i18n' { + export namespace UserConfig { + export interface LanguageOptions { + en: true; + es: true; + fr: true; + } + } +} + +const VALID_LANGUAGES = ['en', 'es', 'fr']; + +function detectLanguage(): Language { + if (typeof window !== 'undefined') { + const param = new URLSearchParams(window.location.search).get('lang'); + if (param && VALID_LANGUAGES.includes(param)) { + return param as Language; + } + } + return 'en'; +} i18n.init({ + defaultLanguage: detectLanguage(), translations: {} }); diff --git a/apps/web/src/components/AppErrorComponent.tsx b/apps/web/src/components/AppErrorComponent.tsx index ed9b048c4..18015e1dd 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 conectar con el servidor. Esto suele ser temporal — por favor, inténtelo de nuevo.', 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 está desconectado. La reconexión se realizará automáticamente en cuanto vuelva la conexión.', 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 41c934b62..3f741b5e0 100644 --- a/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx +++ b/apps/web/src/components/AssignmentEmailForm/AssignmentEmailForm.tsx @@ -51,7 +51,7 @@ export const AssignmentEmailForm: React.FC<{ const selectedTemplate = templateChoice ?? activeValue; const templateOptions = [ { - label: t({ en: 'Built-in default', fr: 'Modèle par défaut' }), + label: t({ en: 'Built-in default', es: 'Predeterminado integrado', fr: 'Modèle par défaut' }), value: DEFAULT_TEMPLATE_OPTION }, ...remoteTemplates.map((tpl) => ({ label: tpl.name, value: tpl.id })) @@ -80,12 +80,13 @@ export const AssignmentEmailForm: React.FC<{ onError: () => { const message = t({ en: 'The email could not be sent', + es: 'No se pudo enviar el correo electrónico', fr: "Le courriel n'a pas pu être envoyé" }); setFeedback({ message, tone: 'error' }); addNotification({ message, - title: t({ en: 'Email failed', fr: 'Échec du courriel' }), + title: t({ en: 'Email failed', es: 'Error de correo electrónico', fr: 'Échec du courriel' }), type: 'error' }); }, @@ -93,12 +94,13 @@ export const AssignmentEmailForm: React.FC<{ if (result.status === 'SENT') { const message = t({ en: `Assignment link sent to ${recipient}`, + es: `Enlace de evaluación enviado a ${recipient}`, fr: `Lien d'évaluation envoyé à ${recipient}` }); setFeedback({ message, tone: 'success' }); addNotification({ message, - title: t({ en: 'Email sent', fr: 'Courriel envoyé' }), + title: t({ en: 'Email sent', es: 'Correo electrónico enviado', fr: 'Courriel envoyé' }), type: 'success' }); setRecipient(''); @@ -107,12 +109,13 @@ export const AssignmentEmailForm: React.FC<{ result.error ?? t({ en: 'The email could not be sent', + es: 'No se pudo enviar el correo electrónico', fr: "Le courriel n'a pas pu être envoyé" }); setFeedback({ message, tone: 'error' }); addNotification({ message, - title: t({ en: 'Email failed', fr: 'Échec du courriel' }), + title: t({ en: 'Email failed', es: 'Error de correo electrónico', fr: 'Échec du courriel' }), type: 'error' }); } @@ -134,7 +137,9 @@ export const AssignmentEmailForm: React.FC<{
{remoteTemplates.length > 0 && (
- + setName(event.target.value)} />
- +
- +
- + {AVAILABLE_VARS[category].length > 0 && (
- {t({ en: 'Insert:', fr: 'Insérer :' })} + + {t({ en: 'Insert:', es: 'Insertar:', fr: 'Insérer :' })} + {AVAILABLE_VARS[category].map((variable) => ( ) : ( )}
@@ -471,11 +477,12 @@ export const GroupEmailTemplates = () => { {t({ en: 'Your Open Data Capture Assignment (built-in)', + es: 'Su evaluación de Open Data Capture (integrado)', fr: 'Votre évaluation Open Data Capture (intégré)' })} ) : ( )}
@@ -524,12 +531,14 @@ export const GroupEmailTemplates = () => { {t({ en: 'Remote Assignment Templates', + es: 'Plantillas de evaluación remota', fr: "Modèles d'évaluation à distance" })}

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

@@ -541,11 +550,12 @@ export const GroupEmailTemplates = () => {
- {t({ en: 'Information Templates', fr: "Modèles d'information" })} + {t({ en: 'Information Templates', es: 'Plantillas de información', fr: "Modèles d'information" })}

{t({ en: 'General study-related messages for your participants.', + es: 'Mensajes generales relacionados con el estudio para sus participantes.', fr: "Messages généraux liés à l'étude pour vos participants." })}

@@ -555,6 +565,7 @@ export const GroupEmailTemplates = () => { {t({ en: 'No information templates yet — create one to get started.', + es: 'Aún no hay plantillas de información — cree una para comenzar.', fr: "Aucun modèle d'information — créez-en un pour commencer." })} @@ -565,7 +576,7 @@ export const GroupEmailTemplates = () => { className="flex flex-col gap-3 rounded-2xl border border-slate-200/60 p-5 dark:border-slate-700/60" onSubmit={(event) => void handleCreate(event)} > - {t({ en: 'New template', fr: 'Nouveau modèle' })} + {t({ en: 'New template', es: 'Nueva plantilla', fr: 'Nouveau modèle' })} { />
@@ -592,19 +603,21 @@ export const GroupEmailTemplates = () => { {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 enviado para las evaluaciones remotas cuando no hay una plantilla personalizada activa.', fr: "Il s'agit du message envoyé pour les évaluations à distance lorsqu'aucun modèle personnalisé n'est actif." })}
- +
- +
- +