diff --git a/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx b/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx
index 9b4731505..e342b4645 100644
--- a/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx
+++ b/apps/web/src/components/EmailTemplateEditor/EmailTemplateEditor.tsx
@@ -11,6 +11,8 @@ export type EmailTemplateEditorProps = {
error?: string;
/** Namespaces the control ids and test ids, so two editors can coexist on one page. */
idPrefix: string;
+ /** The languages to offer in the language picker; defaults to all supported languages. */
+ languages?: readonly Language[];
/** Omit to show the template read-only. */
onChange?: (template: MailTemplate) => void;
template: MailTemplate;
@@ -22,6 +24,7 @@ export type EmailTemplateEditorProps = {
export const EmailTemplateEditor = ({
error,
idPrefix,
+ languages,
onChange,
template,
variables = []
@@ -56,6 +59,7 @@ export const EmailTemplateEditor = ({
diff --git a/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx b/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx
index f5d49cd72..e49ff33fa 100644
--- a/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx
+++ b/apps/web/src/components/GroupEmailTemplates/CreateTemplateForm.tsx
@@ -2,15 +2,17 @@ import React from 'react';
import { Button, Dialog, Heading, Input, Label } from '@douglasneuroinformatics/libui/components';
import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import type { Language } from '@opendatacapture/schemas/core';
import type { MailTemplate } from '@opendatacapture/schemas/mail';
import { EmailTemplateEditor } from '@/components/EmailTemplateEditor';
import { ASSIGNMENT_TEMPLATE_VARS } from '@/utils/email-template';
-import { LANGUAGE_LABELS, LANGUAGES } from '@/utils/language';
+import { LANGUAGE_LABELS } from '@/utils/language';
const emptyTemplate = (): MailTemplate => ({ body: {}, subject: {} });
export type CreateTemplateFormProps = {
+ activeLanguages: readonly Language[];
isPending: boolean;
/** Resolves true once the template is saved; the form clears only then. */
onCreate: (name: string, template: MailTemplate) => Promise;
@@ -18,7 +20,13 @@ export type CreateTemplateFormProps = {
validateName: (name: string) => string | undefined;
};
-export const CreateTemplateForm = ({ isPending, onCreate, validateContent, validateName }: CreateTemplateFormProps) => {
+export const CreateTemplateForm = ({
+ activeLanguages,
+ isPending,
+ onCreate,
+ validateContent,
+ validateName
+}: CreateTemplateFormProps) => {
const { t } = useTranslation();
const addNotification = useNotificationsStore((store) => store.addNotification);
const formRef = React.useRef(null);
@@ -27,7 +35,7 @@ export const CreateTemplateForm = ({ isPending, onCreate, validateContent, valid
const [isConfirmingMissingTranslations, setIsConfirmingMissingTranslations] = React.useState(false);
const contentError = validateContent(draft);
- const missingLanguages = LANGUAGES.filter((code) => !draft.body[code]?.trim());
+ const missingLanguages = activeLanguages.filter((code) => !draft.body[code]?.trim());
const create = async () => {
if (!(await onCreate(name.trim(), draft))) {
@@ -76,6 +84,7 @@ export const CreateTemplateForm = ({ isPending, onCreate, validateContent, valid
void;
onSave: (template: GroupEmailTemplate) => void;
@@ -18,6 +20,7 @@ type EditTemplateFormProps = {
};
const EditTemplateForm = ({
+ activeLanguages,
isPending,
onCancel,
onSave,
@@ -62,6 +65,7 @@ const EditTemplateForm = ({
void;
onSave: (template: GroupEmailTemplate) => void;
@@ -104,6 +109,7 @@ export const EditTemplateDialog = ({ template, ...props }: EditTemplateDialogPro
{/* Mounted only while open, so the draft resets between templates. */}
{template && (
(
export const GroupEmailTemplates = () => {
const { t } = useTranslation();
const addNotification = useNotificationsStore((store) => store.addNotification);
+ const { data: setupState } = useSetupStateQuery();
+ const activeLanguages = setupState.activeLanguages;
const currentGroup = useAppStore((store) => store.currentGroup);
const groupId = currentGroup?.id;
const queryClient = useQueryClient();
@@ -280,15 +283,21 @@ export const GroupEmailTemplates = () => {
validateName(name)}
onCreate={handleCreate}
/>
-
+
void;
open: boolean;
};
/** Read-only view of the message sent when no custom template is active. */
-export const ViewDefaultTemplateDialog = ({ onOpenChange, open }: ViewDefaultTemplateDialogProps) => {
+export const ViewDefaultTemplateDialog = ({ activeLanguages, onOpenChange, open }: ViewDefaultTemplateDialogProps) => {
const { t } = useTranslation();
return (
@@ -31,7 +33,11 @@ export const ViewDefaultTemplateDialog = ({ onOpenChange, open }: ViewDefaultTem
})}
-
+
onOpenChange(false)}>
{t('core.close')}
diff --git a/apps/web/src/components/GroupEmailTemplates/__tests__/CreateTemplateForm.test.tsx b/apps/web/src/components/GroupEmailTemplates/__tests__/CreateTemplateForm.test.tsx
index a66cb5c3e..9fb4d9daf 100644
--- a/apps/web/src/components/GroupEmailTemplates/__tests__/CreateTemplateForm.test.tsx
+++ b/apps/web/src/components/GroupEmailTemplates/__tests__/CreateTemplateForm.test.tsx
@@ -23,6 +23,7 @@ const renderForm = ({
}: RenderOptions = {}) => {
render(
{
const { onCreate } = renderForm();
fillEnglishOnly('Reminder');
fireEvent.click(submitButton());
- expect(screen.getByText('Warning: This template is missing translations for: Spanish, French.')).toBeTruthy();
+ expect(screen.getByText('Warning: This template is missing translations for: French.')).toBeTruthy();
expect(onCreate).not.toHaveBeenCalled();
});
@@ -116,11 +117,9 @@ describe('CreateTemplateForm', () => {
expect(onCreate).not.toHaveBeenCalled();
});
- it('should create without a warning when every language is translated', () => {
+ it('should create without a warning when every active language is translated', () => {
const { onCreate } = renderForm();
fillEnglishOnly('Reminder');
- switchLanguage('Spanish');
- fillContent('Por favor', 'Abra {{url}} antes de {{expiresAt}}');
switchLanguage('French');
fillContent('Veuillez', 'Ouvrez {{url}} avant {{expiresAt}}');
fireEvent.click(submitButton());
diff --git a/apps/web/src/components/GroupEmailTemplates/__tests__/EditTemplateDialog.test.tsx b/apps/web/src/components/GroupEmailTemplates/__tests__/EditTemplateDialog.test.tsx
index 957207219..ef88affac 100644
--- a/apps/web/src/components/GroupEmailTemplates/__tests__/EditTemplateDialog.test.tsx
+++ b/apps/web/src/components/GroupEmailTemplates/__tests__/EditTemplateDialog.test.tsx
@@ -1,5 +1,6 @@
import { useNotificationsStore } from '@douglasneuroinformatics/libui/hooks';
import { i18n } from '@douglasneuroinformatics/libui/i18n';
+import type { Language } from '@opendatacapture/schemas/core';
import type { GroupEmailTemplate } from '@opendatacapture/schemas/group';
import type { MailTemplate } from '@opendatacapture/schemas/mail';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
@@ -16,7 +17,11 @@ const template: GroupEmailTemplate = {
subject: { en: 'Please complete' }
};
+/** Spanish deliberately left out, so a test can tell a scoped picker from an unscoped one. */
+const ACTIVE_LANGUAGES: readonly Language[] = ['en', 'fr'];
+
type RenderOptions = {
+ activeLanguages?: readonly Language[];
isPending?: boolean;
validateContent?: (template: MailTemplate) => string | undefined;
validateName?: (name: string) => string | undefined;
@@ -24,6 +29,7 @@ type RenderOptions = {
};
const renderDialog = ({
+ activeLanguages = ACTIVE_LANGUAGES,
isPending = false,
validateContent = () => undefined,
validateName = () => undefined,
@@ -33,6 +39,7 @@ const renderDialog = ({
const onSave = vi.fn();
render(
({
useAppStore: vi.fn((selector: (state: typeof store) => unknown) => selector(store))
}));
+vi.mock('@/hooks/useSetupStateQuery', () => ({
+ useSetupStateQuery: () => ({ data: { activeLanguages: ['en', 'fr'] } })
+}));
+
const reminder: GroupEmailTemplate = {
body: { en: 'Open {{url}} before {{expiresAt}}' },
id: 'tpl-a',
diff --git a/apps/web/src/components/GroupEmailTemplates/__tests__/ViewDefaultTemplateDialog.test.tsx b/apps/web/src/components/GroupEmailTemplates/__tests__/ViewDefaultTemplateDialog.test.tsx
index 0f1a75fe5..f5dee33d4 100644
--- a/apps/web/src/components/GroupEmailTemplates/__tests__/ViewDefaultTemplateDialog.test.tsx
+++ b/apps/web/src/components/GroupEmailTemplates/__tests__/ViewDefaultTemplateDialog.test.tsx
@@ -1,4 +1,5 @@
import { i18n } from '@douglasneuroinformatics/libui/i18n';
+import type { Language } from '@opendatacapture/schemas/core';
import { DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
@@ -7,6 +8,9 @@ import { ViewDefaultTemplateDialog } from '../ViewDefaultTemplateDialog';
import '@/services/i18n';
+/** Spanish deliberately left out, so a test can tell a scoped picker from an unscoped one. */
+const ACTIVE_LANGUAGES: readonly Language[] = ['en', 'fr'];
+
describe('ViewDefaultTemplateDialog', () => {
beforeEach(() => {
i18n.changeLanguage('en');
@@ -15,12 +19,12 @@ describe('ViewDefaultTemplateDialog', () => {
afterEach(cleanup);
it('should render nothing while closed', () => {
- render( );
+ render( );
expect(screen.queryByText('Built-in default template')).toBeNull();
});
it('should show the built-in subject and body when open', () => {
- render( );
+ render( );
expect(screen.getByTestId('template-builtin-subject').value).toBe(
DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject.en
);
@@ -30,15 +34,22 @@ describe('ViewDefaultTemplateDialog', () => {
});
it('should make the built-in subject and body read-only, since the default template cannot be edited', () => {
- render( );
+ render( );
expect(screen.getByTestId('template-builtin-subject').readOnly).toBe(true);
expect(screen.getByTestId('template-builtin-body').readOnly).toBe(true);
});
it('should request closing when the close button is clicked', () => {
const onOpenChange = vi.fn();
- render( );
+ render( );
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(onOpenChange).toHaveBeenCalledWith(false);
});
+
+ // Offering a language the instance has disabled invites authoring copy nothing will ever send.
+ it('should offer only the languages the instance has enabled', () => {
+ render( );
+ fireEvent.keyDown(screen.getByTestId('template-builtin-language'), { key: 'Enter' });
+ expect(screen.getAllByRole('option').map((option) => option.textContent)).toStrictEqual(['English', 'French']);
+ });
});
diff --git a/apps/web/src/components/MailSettings/MailSettings.tsx b/apps/web/src/components/MailSettings/MailSettings.tsx
index 6a0ba9382..051afc5a0 100644
--- a/apps/web/src/components/MailSettings/MailSettings.tsx
+++ b/apps/web/src/components/MailSettings/MailSettings.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Checkbox, Heading } from '@douglasneuroinformatics/libui/components';
import { useNotificationsStore, useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import type { Language } from '@opendatacapture/schemas/core';
import { $UpdateMailConfigData, isSameMailServer } from '@opendatacapture/schemas/mail';
import type { MailConfigDto, MailTemplate, UpdateMailConfigData } from '@opendatacapture/schemas/mail';
import { z } from 'zod/v4';
@@ -32,11 +33,12 @@ const initialValues = (config: MailConfigDto | null): MailConfigFormValues => ({
});
export type MailSettingsProps = {
+ activeLanguages: readonly Language[];
config: MailConfigDto | null;
newUserEmailTemplate: MailTemplate;
};
-export const MailSettings = ({ config, newUserEmailTemplate }: MailSettingsProps) => {
+export const MailSettings = ({ activeLanguages, config, newUserEmailTemplate }: MailSettingsProps) => {
const { t } = useTranslation();
const addNotification = useNotificationsStore((store) => store.addNotification);
const updateMutation = useUpdateMailSettingsMutation();
@@ -202,6 +204,7 @@ export const MailSettings = ({ config, newUserEmailTemplate }: MailSettingsProps
void save({ newUserEmailTemplate: next })}
diff --git a/apps/web/src/components/MailSettings/NewUserTemplateSection.tsx b/apps/web/src/components/MailSettings/NewUserTemplateSection.tsx
index 644aa4c36..86d5dfb01 100644
--- a/apps/web/src/components/MailSettings/NewUserTemplateSection.tsx
+++ b/apps/web/src/components/MailSettings/NewUserTemplateSection.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { Button, Heading } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
+import type { Language } from '@opendatacapture/schemas/core';
import { DEFAULT_NEW_USER_EMAIL_TEMPLATE } from '@opendatacapture/schemas/mail';
import type { MailTemplate } from '@opendatacapture/schemas/mail';
import { Loader2Icon } from 'lucide-react';
@@ -9,7 +10,7 @@ import { Loader2Icon } from 'lucide-react';
import { EmailTemplateEditor } from '@/components/EmailTemplateEditor';
import { SectionCard } from '@/components/SectionCard';
import { checkTemplateIssue, NEW_USER_TEMPLATE_VARS, REQUIRED_NEW_USER_TEMPLATE_VARS } from '@/utils/email-template';
-import { LANGUAGES, omitBlankLanguages } from '@/utils/language';
+import { omitBlankLanguages } from '@/utils/language';
const defaultTemplate = (): MailTemplate => ({
body: { ...DEFAULT_NEW_USER_EMAIL_TEMPLATE.body },
@@ -17,13 +18,19 @@ const defaultTemplate = (): MailTemplate => ({
});
export type NewUserTemplateSectionProps = {
+ activeLanguages: readonly Language[];
isSaving: boolean;
onSave: (template: MailTemplate) => void;
template: MailTemplate;
};
/** Authors the welcome message sent when a user with an email address is created. */
-export const NewUserTemplateSection = ({ isSaving, onSave, template: saved }: NewUserTemplateSectionProps) => {
+export const NewUserTemplateSection = ({
+ activeLanguages,
+ isSaving,
+ onSave,
+ template: saved
+}: NewUserTemplateSectionProps) => {
const { t } = useTranslation();
const [template, setTemplate] = React.useState(() => ({
body: { ...DEFAULT_NEW_USER_EMAIL_TEMPLATE.body, ...saved.body },
@@ -32,7 +39,7 @@ export const NewUserTemplateSection = ({ isSaving, onSave, template: saved }: Ne
// Unlike a group template, this one is sent in whichever language the admin picks at creation
// time, so every language has to be filled in rather than only the ones started.
- const issue = checkTemplateIssue(template.subject, template.body, REQUIRED_NEW_USER_TEMPLATE_VARS, LANGUAGES);
+ const issue = checkTemplateIssue(template.subject, template.body, REQUIRED_NEW_USER_TEMPLATE_VARS, activeLanguages);
const error =
issue === 'incomplete'
? t({
@@ -79,6 +86,7 @@ export const NewUserTemplateSection = ({ isSaving, onSave, template: saved }: Ne
{
const queryClient = new QueryClient({ defaultOptions: { mutations: { retry: false } } });
render(
-
+
);
};
diff --git a/apps/web/src/components/MailSettings/__tests__/NewUserTemplateSection.test.tsx b/apps/web/src/components/MailSettings/__tests__/NewUserTemplateSection.test.tsx
index b5417c7b9..7c34c18a1 100644
--- a/apps/web/src/components/MailSettings/__tests__/NewUserTemplateSection.test.tsx
+++ b/apps/web/src/components/MailSettings/__tests__/NewUserTemplateSection.test.tsx
@@ -13,9 +13,19 @@ const spanishOnly: MailTemplate = {
subject: { es: 'Su cuenta' }
};
-const renderSection = (template: MailTemplate = spanishOnly, isSaving = false) => {
+const ALL_LANGUAGES = ['en', 'es', 'fr'] as const;
+
+const renderSection = (
+ template: MailTemplate = spanishOnly,
+ {
+ activeLanguages = ALL_LANGUAGES,
+ isSaving = false
+ }: { activeLanguages?: readonly ('en' | 'es' | 'fr')[]; isSaving?: boolean } = {}
+) => {
const onSave = vi.fn();
- render( );
+ render(
+
+ );
return { onSave };
};
@@ -44,8 +54,8 @@ describe('NewUserTemplateSection', () => {
expect(subjectInput().value).toBe('Welcome aboard');
});
- it('should require every language, since the admin may send the message in any of them', () => {
- renderSection({ body: {}, subject: {} });
+ it('should require every active language, since the admin may send the message in any of them', () => {
+ renderSection({ body: { en: 'Hello {{username}}', es: '' }, subject: { en: 'Welcome', es: '' } });
expect(errorText()).toBe('Fill in the subject and body for each language.');
expect(saveButton().hasAttribute('disabled')).toBe(true);
});
@@ -79,7 +89,7 @@ describe('NewUserTemplateSection', () => {
});
it('should disable saving and show a spinner while a save is in flight', () => {
- renderSection(spanishOnly, true);
+ renderSection(spanishOnly, { isSaving: true });
expect(saveButton().hasAttribute('disabled')).toBe(true);
expect(saveButton().querySelector('svg')).not.toBeNull();
});
@@ -88,4 +98,13 @@ describe('NewUserTemplateSection', () => {
renderSection();
expect(saveButton().querySelector('svg')).toBeNull();
});
+
+ it('should not require inactive languages, so a template missing Spanish is valid when only English and French are enabled', () => {
+ renderSection(
+ { body: { en: 'Hello, your user is {{username}}' }, subject: { en: 'Your account' } },
+ { activeLanguages: ['en'] }
+ );
+ expect(errorText()).toBeUndefined();
+ expect(saveButton().hasAttribute('disabled')).toBe(false);
+ });
});
diff --git a/apps/web/src/routes/_app/admin/__tests__/mail.test.tsx b/apps/web/src/routes/_app/admin/__tests__/mail.test.tsx
index 1a32b411c..8238ad167 100644
--- a/apps/web/src/routes/_app/admin/__tests__/mail.test.tsx
+++ b/apps/web/src/routes/_app/admin/__tests__/mail.test.tsx
@@ -34,6 +34,9 @@ vi.mock('@/hooks/useMailSettingsQuery', () => ({
mailSettingsQueryOptions: mocks.mailSettingsQueryOptions,
useMailSettingsQuery: () => ({ data: SETTINGS })
}));
+vi.mock('@/hooks/useSetupStateQuery', () => ({
+ useSetupStateQuery: () => ({ data: { activeLanguages: ['en', 'fr'] } })
+}));
const runLoader = (queryClient: QueryClient) => {
const { loader } = Route.options;
@@ -71,6 +74,7 @@ describe('admin mail route', () => {
renderPage();
expect(screen.getByTestId('mail-settings-page').contains(screen.getByTestId('mail-settings-form'))).toBe(true);
expect(mocks.MailSettings.mock.lastCall?.[0]).toEqual({
+ activeLanguages: ['en', 'fr'],
config: SETTINGS.config,
newUserEmailTemplate: SETTINGS.newUserEmailTemplate
});
diff --git a/apps/web/src/routes/_app/admin/mail.tsx b/apps/web/src/routes/_app/admin/mail.tsx
index 58b86ce6d..5d99264d6 100644
--- a/apps/web/src/routes/_app/admin/mail.tsx
+++ b/apps/web/src/routes/_app/admin/mail.tsx
@@ -5,10 +5,12 @@ import { createFileRoute } from '@tanstack/react-router';
import { MailSettings } from '@/components/MailSettings';
import { PageHeader } from '@/components/PageHeader';
import { mailSettingsQueryOptions, useMailSettingsQuery } from '@/hooks/useMailSettingsQuery';
+import { useSetupStateQuery } from '@/hooks/useSetupStateQuery';
const RouteComponent = () => {
const { t } = useTranslation();
const { data: settings } = useMailSettingsQuery();
+ const { data: setupState } = useSetupStateQuery();
return (
@@ -18,7 +20,11 @@ const RouteComponent = () => {
-
+
);
diff --git a/apps/web/src/routes/_app/admin/settings.tsx b/apps/web/src/routes/_app/admin/settings.tsx
index 65c9ea53b..7d759994e 100644
--- a/apps/web/src/routes/_app/admin/settings.tsx
+++ b/apps/web/src/routes/_app/admin/settings.tsx
@@ -290,23 +290,25 @@ const RouteComponent = () => {
- {LANGUAGES.map((language) => {
- const isActive = activeLanguages.includes(language);
- return (
-
- toggleLanguage(language)}
- />
-
- {t(LANGUAGE_LABELS[language])}
-
-
- );
- })}
+ {[...LANGUAGES]
+ .sort((a, b) => t(LANGUAGE_LABELS[a]).localeCompare(t(LANGUAGE_LABELS[b])))
+ .map((language) => {
+ const isActive = activeLanguages.includes(language);
+ return (
+
+ toggleLanguage(language)}
+ />
+
+ {t(LANGUAGE_LABELS[language])}
+
+
+ );
+ })}
diff --git a/packages/schemas/src/mail/__tests__/mail.test.ts b/packages/schemas/src/mail/__tests__/mail.test.ts
index dfb7935a4..24a434885 100644
--- a/packages/schemas/src/mail/__tests__/mail.test.ts
+++ b/packages/schemas/src/mail/__tests__/mail.test.ts
@@ -113,13 +113,13 @@ describe('isSameMailServer', () => {
});
describe('default templates', () => {
- it.each(['en', 'fr'] as const)('should carry the required placeholders in %s', (language) => {
+ it.each(['en', 'es', 'fr'] as const)('should carry the required placeholders in %s', (language) => {
expect(DEFAULT_NEW_USER_EMAIL_TEMPLATE.body[language]).toContain('{{username}}');
expect(DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body[language]).toContain('{{url}}');
expect(DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.body[language]).toContain('{{expiresAt}}');
});
- it.each(['en', 'fr'] as const)('should have a non-empty subject and body in %s', (language) => {
+ it.each(['en', 'es', 'fr'] as const)('should have a non-empty subject and body in %s', (language) => {
expect(DEFAULT_NEW_USER_EMAIL_TEMPLATE.subject[language].trim()).toBeTruthy();
expect(DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE.subject[language].trim()).toBeTruthy();
});
diff --git a/packages/schemas/src/mail/mail.ts b/packages/schemas/src/mail/mail.ts
index af9b79f7a..ff1823274 100644
--- a/packages/schemas/src/mail/mail.ts
+++ b/packages/schemas/src/mail/mail.ts
@@ -174,6 +174,21 @@ const DEFAULT_NEW_USER_EMAIL_TEMPLATE = {
'New to Open Data Capture? This guide will help you get started:',
'https://opendatacapture.org/en/docs/guides/how-to-get-started-with-odc/'
].join('\n'),
+ es: [
+ 'Hola {{firstName}},',
+ '',
+ 'Se ha creado una cuenta para usted en Open Data Capture.',
+ '',
+ 'Nombre de usuario: {{username}}',
+ 'Grupo(s): {{group}}',
+ '',
+ 'Puede iniciar sesión aquí: {{url}}',
+ '',
+ 'Su administrador le proporcionará su contraseña por separado. Por seguridad, cámbiela después de iniciar sesión por primera vez.',
+ '',
+ '¿Nuevo en Open Data Capture? Esta guía le ayudará a comenzar:',
+ 'https://opendatacapture.org/en/docs/guides/how-to-get-started-with-odc/'
+ ].join('\n'),
fr: [
'Bonjour {{firstName}},',
'',
@@ -192,6 +207,7 @@ const DEFAULT_NEW_USER_EMAIL_TEMPLATE = {
},
subject: {
en: 'Your Open Data Capture account',
+ es: 'Su cuenta de Open Data Capture',
fr: 'Votre compte Open Data Capture'
}
} as const satisfies z.infer;
@@ -211,6 +227,18 @@ const DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE = {
'',
'Thank you.'
].join('\n'),
+ es: [
+ 'Hola,',
+ '',
+ 'Se le ha asignado un cuestionario para completar en Open Data Capture.',
+ '',
+ 'Por favor, complételo utilizando el enlace seguro a continuación:',
+ '{{url}}',
+ '',
+ 'Este enlace expira el {{expiresAt}}.',
+ '',
+ 'Gracias.'
+ ].join('\n'),
fr: [
'Bonjour,',
'',
@@ -226,6 +254,7 @@ const DEFAULT_ASSIGNMENT_EMAIL_TEMPLATE = {
},
subject: {
en: 'Your Open Data Capture assignment',
+ es: 'Su evaluación de Open Data Capture',
fr: 'Votre évaluation Open Data Capture'
}
} as const satisfies z.infer;