Add i18n support to email-templates

The email-templates package now uses the i18n (internationalization) library to translate email content based on user language preferences. This update involves changes to the email structure, the addition of translation JSON files, and modifications to the files referencing the emails. Email file locations were also restructured for better organization.
This commit is contained in:
giancarlo
2024-05-01 17:35:48 +07:00
parent 3220526a30
commit 15f6c72c6e
10 changed files with 152 additions and 37 deletions

View File

@@ -19,7 +19,8 @@
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*"
"@kit/tsconfig": "workspace:*",
"@kit/i18n": "workspace:*"
},
"eslintConfig": {
"root": true,

View File

@@ -10,53 +10,75 @@ import {
render,
} from '@react-email/components';
import { initializeEmailI18n } from '../lib/i18n';
interface Props {
productName: string;
userDisplayName: string;
language?: string;
}
export function renderAccountDeleteEmail(props: Props) {
const previewText = `We have deleted your ${props.productName} account`;
export async function renderAccountDeleteEmail(props: Props) {
const namespace = 'account-delete-email';
return render(
const { t } = await initializeEmailI18n({
language: props.language,
namespace,
});
const previewText = t(`${namespace}:previewText`, {
productName: props.productName,
});
const subject = t(`${namespace}:subject`);
const html = render(
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-gray-50 font-sans">
<Body className="mx-auto my-auto bg-[#ffffff] font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded-lg border border-solid border-[#eaeaea] bg-white p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-bold text-black">
{previewText}
</Heading>
<Text className="text-[14px] leading-[24px] text-black">
Hello {props.userDisplayName},
{t(`${namespace}:hello`, {
displayName: props.userDisplayName,
})}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
This is to confirm that we&apos;ve processed your request to
delete your account with {props.productName}.
{t(`${namespace}:paragraph1`, {
productName: props.productName,
})}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
We&apos;re sorry to see you go. Please note that this action is
irreversible, and we&apos;ll make sure to delete all of your data
from our systems.
{t(`${namespace}:paragraph2`)}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
We thank you again for using {props.productName}.
{t(`${namespace}:paragraph3`, {
productName: props.productName,
})}
</Text>
<Text className="text-[14px] leading-[24px] text-black">
Best,
<br />
The {props.productName} Team
{t(`${namespace}:paragraph4`, {
productName: props.productName,
})}
</Text>
</Container>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -17,6 +17,8 @@ import {
render,
} from '@react-email/components';
import { initializeEmailI18n } from '../lib/i18n';
interface Props {
teamName: string;
teamLogo?: string;
@@ -24,31 +26,60 @@ interface Props {
invitedUserEmail: string;
link: string;
productName: string;
language?: string;
}
export function renderInviteEmail(props: Props) {
const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`;
export async function renderInviteEmail(props: Props) {
const namespace = 'invite-email';
return render(
const { t } = await initializeEmailI18n({
language: props.language,
namespace,
});
const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`;
const subject = t(`${namespace}:subject`);
const heading = t(`${namespace}:heading`, {
teamName: props.teamName,
productName: props.productName,
});
const hello = t(`${namespace}:hello`, {
invitedUserEmail: props.invitedUserEmail,
});
const mainText = t(`${namespace}:mainText`, {
inviter: props.inviter,
teamName: props.teamName,
productName: props.productName,
});
const joinTeam = t(`${namespace}:joinTeam`, {
teamName: props.teamName,
});
const html = render(
<Html>
<Head />
<Preview>{previewText}</Preview>
<Tailwind>
<Body className="mx-auto my-auto bg-gray-50 font-sans">
<Body className="mx-auto my-auto bg-[#ffffff] font-sans">
<Container className="mx-auto my-[40px] w-[465px] rounded-lg border border-solid border-[#eaeaea] bg-white p-[20px]">
<Heading className="mx-0 my-[30px] p-0 text-center text-[24px] font-normal text-black">
Join <strong>{props.teamName}</strong> on{' '}
<strong>{props.productName}</strong>
{heading}
</Heading>
<Text className="text-[14px] leading-[24px] text-black">
Hello {props.invitedUserEmail},
</Text>
<Text className="text-[14px] leading-[24px] text-black">
<strong>{props.inviter}</strong> has invited you to the{' '}
<strong>{props.teamName}</strong> team on{' '}
<strong>{props.productName}</strong>.
{hello}
</Text>
<Text
className="text-[14px] leading-[24px] text-black"
dangerouslySetInnerHTML={{ __html: mainText }}
/>
{props.teamLogo && (
<Section>
<Row>
@@ -63,28 +94,38 @@ export function renderInviteEmail(props: Props) {
</Row>
</Section>
)}
<Section className="mb-[32px] mt-[32px] text-center">
<Button
className="rounded bg-[#000000] px-[20px] py-[12px] text-center text-[12px] font-semibold text-white no-underline"
href={props.link}
>
Join {props.teamName}
{joinTeam}
</Button>
</Section>
<Text className="text-[14px] leading-[24px] text-black">
or copy and paste this URL into your browser:{' '}
{t(`${namespace}:copyPasteLink`)}{' '}
<Link href={props.link} className="text-blue-600 no-underline">
{props.link}
</Link>
</Text>
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
<Text className="text-[12px] leading-[24px] text-[#666666]">
This invitation was intended for{' '}
<span className="text-black">{props.invitedUserEmail}</span>.
{t(`${namespace}:invitationIntendedFor`, {
invitedUserEmail: props.invitedUserEmail,
})}
</Text>
</Container>
</Body>
</Tailwind>
</Html>,
);
return {
html,
subject,
};
}

View File

@@ -1,2 +1,2 @@
export * from './invite.email';
export * from './account-delete.email';
export * from './emails/invite.email';
export * from './emails/account-delete.email';

View File

@@ -0,0 +1,30 @@
import { initializeServerI18n } from '@kit/i18n/server';
export function initializeEmailI18n(params: {
language: string | undefined;
namespace: string;
}) {
const language = params.language ?? 'en';
return initializeServerI18n(
{
supportedLngs: [language],
lng: language,
ns: params.namespace,
},
async (language, namespace) => {
try {
const data = await import(`../locales/${language}/${namespace}.json`);
return data as Record<string, string>;
} catch (error) {
console.log(
`Error loading i18n file: locales/${language}/${namespace}.json`,
error,
);
return {};
}
},
);
}

View File

@@ -0,0 +1,9 @@
{
"subject": "We have deleted your {{productName}} account",
"previewText": "We have deleted your {{productName}} account",
"hello": "Hello {{displayName}},",
"paragraph1": "This is to confirm that we have processed your request to delete your account with {{productName}}.",
"paragraph2": "We're sorry to see you go. Please note that this action is irreversible, and we'll make sure to delete all of your data from our systems.",
"paragraph3": "We thank you again for using {{productName}}.",
"paragraph4": "The {{productName}} Team"
}

View File

@@ -0,0 +1,9 @@
{
"subject": "You have been invited to join a team",
"heading": "Join {{teamName}} on {{productName}}",
"hello": "Hello {{invitedUserEmail}},",
"mainText": "<strong>{{inviter}}</strong> has invited you to the <strong>{{teamName}}</strong> team on <strong>{{productName}}</strong>.",
"joinTeam": "Join {{teamName}}",
"copyPasteLink": "or copy and paste this URL into your browser:",
"invitationIntendedFor": "This invitation is intended for {{invitedUserEmail}}."
}

View File

@@ -103,7 +103,7 @@ class AccountInvitationsWebhookService {
const mailer = await getMailer();
const html = renderInviteEmail({
const { html, subject } = await renderInviteEmail({
link: this.getInvitationLink(invitation.invite_token),
invitedUserEmail: invitation.email,
inviter: inviter.data.name ?? inviter.data.email ?? '',
@@ -115,7 +115,7 @@ class AccountInvitationsWebhookService {
.sendEmail({
from: env.emailSender,
to: invitation.email,
subject: 'You have been invited to join a team',
subject,
html,
})
.then(() => {

View File

@@ -55,7 +55,7 @@ class AccountWebhooksService {
const { getMailer } = await import('@kit/mailers');
const mailer = await getMailer();
const html = renderAccountDeleteEmail({
const { html, subject } = await renderAccountDeleteEmail({
userDisplayName: params.userDisplayName,
productName: params.productName,
});
@@ -63,7 +63,7 @@ class AccountWebhooksService {
return mailer.sendEmail({
to: params.userEmail,
from: params.fromEmail,
subject: 'Account Deletion Request',
subject,
html,
});
}

3
pnpm-lock.yaml generated
View File

@@ -521,6 +521,9 @@ importers:
'@kit/eslint-config':
specifier: workspace:*
version: link:../../tooling/eslint
'@kit/i18n':
specifier: workspace:*
version: link:../i18n
'@kit/prettier-config':
specifier: workspace:*
version: link:../../tooling/prettier