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/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*", "@kit/prettier-config": "workspace:*",
"@kit/tailwind-config": "workspace:*", "@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*" "@kit/tsconfig": "workspace:*",
"@kit/i18n": "workspace:*"
}, },
"eslintConfig": { "eslintConfig": {
"root": true, "root": true,

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
export * from './invite.email'; export * from './emails/invite.email';
export * from './account-delete.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 mailer = await getMailer();
const html = renderInviteEmail({ const { html, subject } = await renderInviteEmail({
link: this.getInvitationLink(invitation.invite_token), link: this.getInvitationLink(invitation.invite_token),
invitedUserEmail: invitation.email, invitedUserEmail: invitation.email,
inviter: inviter.data.name ?? inviter.data.email ?? '', inviter: inviter.data.name ?? inviter.data.email ?? '',
@@ -115,7 +115,7 @@ class AccountInvitationsWebhookService {
.sendEmail({ .sendEmail({
from: env.emailSender, from: env.emailSender,
to: invitation.email, to: invitation.email,
subject: 'You have been invited to join a team', subject,
html, html,
}) })
.then(() => { .then(() => {

View File

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

3
pnpm-lock.yaml generated
View File

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