Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/api/src/setup/dto/update-setup-state.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
22 changes: 13 additions & 9 deletions apps/gateway/src/Root.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<HTMLDivElement>(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
Expand Down Expand Up @@ -79,13 +89,7 @@ export const Root = ({ id, initialSeriesIndex, target, token }: RootProps) => {
<Branding className="[&>span]:hidden sm:[&>span]:block" fontSize="md" />
<div className="flex gap-3">
<ThemeToggle className="h-9 w-9" />
<LanguageToggle
options={{
en: 'English',
fr: 'Français'
}}
triggerClassName="h-9 w-9"
/>
<LanguageToggle options={GATEWAY_LANGUAGES} triggerClassName="h-9 w-9" />
</div>
</div>
</header>
Expand Down
8 changes: 8 additions & 0 deletions apps/gateway/src/entry-client.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,21 @@
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';
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')!,
<React.StrictMode>
Expand Down
6 changes: 6 additions & 0 deletions apps/gateway/src/entry-server.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
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';

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(
<React.StrictMode>
<Root {...props} />
Expand Down
2 changes: 2 additions & 0 deletions apps/gateway/src/routers/root.router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions apps/gateway/src/services/i18n.ts
Original file line number Diff line number Diff line change
@@ -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: {}
});
6 changes: 4 additions & 2 deletions apps/web/src/components/AppErrorComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,24 @@ export const AppErrorComponent = ({ error, reset }: ErrorComponentProps) => {
<WifiOff aria-hidden className="text-muted-foreground h-8! w-8!" />
)}
<h1 className="mt-4 text-2xl font-extrabold tracking-tight sm:text-3xl">
{t({ en: 'Connection Problem', fr: 'Problème de connexion' })}
{t({ en: 'Connection Problem', es: 'Problema de conexión', fr: 'Problème de connexion' })}
</h1>
<p className="text-muted-foreground mt-2 max-w-prose text-sm sm:text-base">
{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.'
})}
</p>
<div className="mt-6">
<Button type="button" variant="primary" onClick={handleRetry}>
{t({ en: 'Try again', fr: 'Réessayer' })}
{t({ en: 'Try again', es: 'Intentar de nuevo', fr: 'Réessayer' })}
</Button>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down Expand Up @@ -80,25 +80,27 @@ 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'
});
},
onSuccess: (result) => {
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('');
Expand All @@ -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'
});
}
Expand All @@ -134,7 +137,9 @@ export const AssignmentEmailForm: React.FC<{
<div className="flex flex-col gap-2">
{remoteTemplates.length > 0 && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="assignment-template">{t({ en: 'Email template', fr: 'Modèle de courriel' })}</Label>
<Label htmlFor="assignment-template">
{t({ en: 'Email template', es: 'Plantilla de correo electrónico', fr: 'Modèle de courriel' })}
</Label>
<Select value={selectedTemplate} onValueChange={setTemplateChoice}>
<Select.Trigger className="w-full" id="assignment-template">
<Select.Value />
Expand All @@ -151,7 +156,7 @@ export const AssignmentEmailForm: React.FC<{
)}
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-1.5">
<Label>{t({ en: 'Email language', fr: 'Langue du courriel' })}</Label>
<Label>{t({ en: 'Email language', es: 'Idioma del correo', fr: 'Langue du courriel' })}</Label>
<Tooltip>
<Tooltip.Trigger className="p-0 hover:bg-transparent" size="icon" variant="ghost">
<CircleHelpIcon className="text-muted-foreground h-4 w-4" />
Expand All @@ -160,6 +165,7 @@ export const AssignmentEmailForm: React.FC<{
<p>
{t({
en: 'Emails and assignments are sent in the selected language when available. However, subjects may still choose a different preferred language on the gateway.',
es: 'Los correos y las evaluaciones se envían en el idioma seleccionado cuando está disponible. Sin embargo, los sujetos aún pueden elegir un idioma preferido diferente en el portal.',
fr: "Les courriels et les évaluations sont envoyés dans la langue sélectionnée lorsqu'elle est disponible. Cependant, les sujets peuvent toujours choisir une langue préférée différente sur le portail."
})}
</p>
Expand All @@ -184,6 +190,7 @@ export const AssignmentEmailForm: React.FC<{
<Label htmlFor="assignment-email">
{t({
en: 'Email link to participant',
es: 'Enviar enlace al participante por correo electrónico',
fr: 'Envoyer le lien au participant par courriel'
})}
</Label>
Expand All @@ -193,6 +200,7 @@ export const AssignmentEmailForm: React.FC<{
id="assignment-email"
placeholder={t({
en: 'recipient@example.org',
es: 'destinatario@ejemplo.org',
fr: 'destinataire@exemple.org'
})}
type="email"
Expand All @@ -207,8 +215,8 @@ export const AssignmentEmailForm: React.FC<{
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 por correo electrónico', fr: 'Envoyer par courriel' })}
</Button>
</div>
{feedback && (
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/components/ConnectivityBanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 conexión…',
fr: 'Hors ligne — en attente de connexion…'
})}
</span>
Expand Down
Loading
Loading