feat: i18n for emails (#1442)

## Description

Support setting a document language that will control the language used
for sending emails to recipients. Additional work has been done to
convert all emails to using our i18n implementation so we can later add
controls for sending other kinds of emails in a users target language.

## Related Issue

N/A

## Changes Made

- Added `<Trans>` and `msg` macros to emails
- Introduced a new `renderEmailWithI18N` utility in the lib package
- Updated all emails to use the `<Tailwind>` component at the top level
due to rendering constraints
- Updated the `i18n.server.tsx` file to not use a top level await

## Testing Performed

- Configured document language and verified emails were sent in the
expected language
- Created a document from a template and verified that the templates
language was transferred to the document
This commit is contained in:
Lucas Smith
2024-11-05 11:52:54 +11:00
committed by GitHub
parent 04b1ce1aab
commit 4dd95016b1
163 changed files with 3549 additions and 1461 deletions

View File

@ -30,8 +30,8 @@ const mdxComponents: MDXComponents = {
*
* Will render the document if it exists, otherwise will return a 404.
*/
export default function ContentPage({ params }: { params: { content: string } }) {
setupI18nSSR();
export default async function ContentPage({ params }: { params: { content: string } }) {
await setupI18nSSR();
const post = allDocuments.find((post) => post._raw.flattenedPath === params.content);

View File

@ -48,8 +48,8 @@ const mdxComponents: MDXComponents = {
),
};
export default function BlogPostPage({ params }: { params: { post: string } }) {
setupI18nSSR();
export default async function BlogPostPage({ params }: { params: { post: string } }) {
await setupI18nSSR();
const post = allBlogPosts.find((post) => post._raw.flattenedPath === `blog/${params.post}`);

View File

@ -9,8 +9,8 @@ export const metadata: Metadata = {
title: 'Blog',
};
export default function BlogPage() {
const { i18n } = setupI18nSSR();
export default async function BlogPage() {
const { i18n } = await setupI18nSSR();
const blogPosts = allBlogPosts.sort((a, b) => {
const dateA = new Date(a.date);

View File

@ -1,7 +1,6 @@
import type { Metadata } from 'next';
import { Trans, msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { z } from 'zod';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
@ -131,9 +130,9 @@ const fetchEarlyAdopters = async () => {
};
export default async function OpenPage() {
setupI18nSSR();
const { i18n } = await setupI18nSSR();
const { _ } = useLingui();
const { _ } = i18n;
const [
{ forks_count: forksCount, stargazers_count: stargazersCount },

View File

@ -26,7 +26,7 @@ const fontCaveat = Caveat({
});
export default async function IndexPage() {
setupI18nSSR();
await setupI18nSSR();
const starCount = await fetch('https://api.github.com/repos/documenso/documenso', {
headers: {

View File

@ -30,8 +30,8 @@ export type PricingPageProps = {
};
};
export default function PricingPage() {
setupI18nSSR();
export default async function PricingPage() {
await setupI18nSSR();
return (
<div className="mt-6 sm:mt-12">

View File

@ -14,8 +14,8 @@ export const dynamic = 'force-dynamic';
// !: This entire file is a hack to get around failed prerendering of
// !: the Single Player Mode page. This regression was introduced during
// !: the upgrade of Next.js to v13.5.x.
export default function SingleplayerPage() {
setupI18nSSR();
export default async function SingleplayerPage() {
await setupI18nSSR();
return <SinglePlayerClient />;
}

View File

@ -56,7 +56,7 @@ export function generateMetadata() {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getAllAnonymousFlags();
const { lang, locales, i18n } = setupI18nSSR();
const { lang, locales, i18n } = await setupI18nSSR();
return (
<html

View File

@ -24,7 +24,7 @@ type AdminDocumentDetailsPageProps = {
};
export default async function AdminDocumentDetailsPage({ params }: AdminDocumentDetailsPageProps) {
const { i18n } = setupI18nSSR();
const { i18n } = await setupI18nSSR();
const document = await getEntireDocument({ id: Number(params.id) });

View File

@ -4,8 +4,8 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { AdminDocumentResults } from './document-results';
export default function AdminDocumentsPage() {
setupI18nSSR();
export default async function AdminDocumentsPage() {
await setupI18nSSR();
return (
<div>

View File

@ -13,7 +13,7 @@ export type AdminSectionLayoutProps = {
};
export default async function AdminSectionLayout({ children }: AdminSectionLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -12,7 +12,7 @@ import { BannerForm } from './banner-form';
// import { BannerForm } from './banner-form';
export default async function AdminBannerPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -30,7 +30,7 @@ import { SignerConversionChart } from './signer-conversion-chart';
import { UserWithDocumentChart } from './user-with-document';
export default async function AdminStatsPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -14,7 +14,7 @@ import {
} from '@documenso/ui/primitives/table';
export default async function Subscriptions() {
setupI18nSSR();
await setupI18nSSR();
const subscriptions = await findSubscriptions();

View File

@ -16,7 +16,7 @@ type AdminManageUsersProps = {
};
export default async function AdminManageUsers({ searchParams = {} }: AdminManageUsersProps) {
setupI18nSSR();
await setupI18nSSR();
const page = Number(searchParams.page) || 1;
const perPage = Number(searchParams.perPage) || 10;

View File

@ -7,6 +7,7 @@ import { useRouter, useSearchParams } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
@ -201,7 +202,7 @@ export const EditDocumentForm = ({
const onAddSettingsFormSubmit = async (data: TAddSettingsFormSchema) => {
try {
const { timezone, dateFormat, redirectUrl } = data.meta;
const { timezone, dateFormat, redirectUrl, language } = data.meta;
await setSettingsForDocument({
documentId: document.id,
@ -217,6 +218,7 @@ export const EditDocumentForm = ({
timezone,
dateFormat,
redirectUrl,
language: isValidLanguageCode(language) ? language : undefined,
},
});

View File

@ -8,8 +8,8 @@ export type DocumentPageProps = {
};
};
export default function DocumentEditPage({ params }: DocumentPageProps) {
setupI18nSSR();
export default async function DocumentEditPage({ params }: DocumentPageProps) {
await setupI18nSSR();
return <DocumentEditPageView params={params} />;
}

View File

@ -6,8 +6,8 @@ import { ChevronLeft, Loader } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { Skeleton } from '@documenso/ui/primitives/skeleton';
export default function Loading() {
setupI18nSSR();
export default async function Loading() {
await setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">

View File

@ -8,8 +8,8 @@ export type DocumentsLogsPageProps = {
};
};
export default function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
setupI18nSSR();
export default async function DocumentsLogsPage({ params }: DocumentsLogsPageProps) {
await setupI18nSSR();
return <DocumentLogsPageView params={params} />;
}

View File

@ -8,8 +8,8 @@ export type DocumentPageProps = {
};
};
export default function DocumentPage({ params }: DocumentPageProps) {
setupI18nSSR();
export default async function DocumentPage({ params }: DocumentPageProps) {
await setupI18nSSR();
return <DocumentPageView params={params} />;
}

View File

@ -5,8 +5,8 @@ import { ChevronLeft } from 'lucide-react';
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function DocumentSentPage() {
setupI18nSSR();
export default async function DocumentSentPage() {
await setupI18nSSR();
return (
<div className="mx-auto -mt-4 flex w-full max-w-screen-xl flex-col px-4 md:px-8">

View File

@ -16,7 +16,7 @@ export const metadata: Metadata = {
};
export default async function DocumentsPage({ searchParams = {} }: DocumentsPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -23,7 +23,7 @@ export type AuthenticatedDashboardLayoutProps = {
export default async function AuthenticatedDashboardLayout({
children,
}: AuthenticatedDashboardLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const session = await getServerSession(NEXT_AUTH_OPTIONS);

View File

@ -24,7 +24,7 @@ export const metadata: Metadata = {
};
export default async function BillingSettingsPage() {
const { i18n } = setupI18nSSR();
const { i18n } = await setupI18nSSR();
let { user } = await getRequiredServerComponentSession();

View File

@ -11,8 +11,8 @@ export type DashboardSettingsLayoutProps = {
children: React.ReactNode;
};
export default function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
setupI18nSSR();
export default async function DashboardSettingsLayout({ children }: DashboardSettingsLayoutProps) {
await setupI18nSSR();
return (
<div className="mx-auto w-full max-w-screen-xl px-4 md:px-8">

View File

@ -17,7 +17,7 @@ export const metadata: Metadata = {
};
export default async function ProfileSettingsPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();

View File

@ -5,7 +5,7 @@ import { getUserPublicProfile } from '@documenso/lib/server-only/user/get-user-p
import { PublicProfilePageView } from './public-profile-page-view';
export default async function Page() {
setupI18nSSR();
await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -14,8 +14,8 @@ export const metadata: Metadata = {
title: 'Security activity',
};
export default function SettingsSecurityActivityPage() {
setupI18nSSR();
export default async function SettingsSecurityActivityPage() {
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -21,7 +21,7 @@ export const metadata: Metadata = {
};
export default async function SecuritySettingsPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
const { user } = await getRequiredServerComponentSession();

View File

@ -17,7 +17,7 @@ export const metadata: Metadata = {
};
export default async function SettingsManagePasskeysPage() {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
const isPasskeyEnabled = await getServerComponentFlag('app_passkey');

View File

@ -10,7 +10,7 @@ import DeleteTokenDialog from '~/components/(dashboard)/settings/token/delete-to
import { ApiTokenForm } from '~/components/forms/token';
export default async function ApiTokensPage() {
const { i18n } = setupI18nSSR();
const { i18n } = await setupI18nSSR();
const { user } = await getRequiredServerComponentSession();

View File

@ -7,6 +7,7 @@ import { useRouter } from 'next/navigation';
import { msg } from '@lingui/macro';
import { useLingui } from '@lingui/react';
import { isValidLanguageCode } from '@documenso/lib/constants/i18n';
import {
DO_NOT_INVALIDATE_QUERY_ON_MUTATION,
SKIP_QUERY_BATCH_META,
@ -151,7 +152,10 @@ export const EditTemplateForm = ({
globalAccessAuth: data.globalAccessAuth ?? null,
globalActionAuth: data.globalActionAuth ?? null,
},
meta: data.meta,
meta: {
...data.meta,
language: isValidLanguageCode(data.meta.language) ? data.meta.language : undefined,
},
});
// Router refresh is here to clear the router cache for when navigating to /documents.

View File

@ -7,8 +7,8 @@ import { TemplatePageView } from './template-page-view';
type TemplatePageProps = Pick<TemplatePageViewProps, 'params'>;
export default function TemplatePage({ params }: TemplatePageProps) {
setupI18nSSR();
export default async function TemplatePage({ params }: TemplatePageProps) {
await setupI18nSSR();
return <TemplatePageView params={params} />;
}

View File

@ -15,8 +15,8 @@ export const metadata: Metadata = {
title: 'Templates',
};
export default function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
setupI18nSSR();
export default async function TemplatesPage({ searchParams = {} }: TemplatesPageProps) {
await setupI18nSSR();
return <TemplatesPageView searchParams={searchParams} />;
}

View File

@ -14,7 +14,7 @@ type PublicProfileLayoutProps = {
};
export default async function PublicProfileLayout({ children }: PublicProfileLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user, session } = await getServerComponentSession();

View File

@ -42,7 +42,7 @@ const BADGE_DATA = {
};
export default async function PublicProfilePage({ params }: PublicProfilePageProps) {
setupI18nSSR();
await setupI18nSSR();
const { url: profileUrl } = params;

View File

@ -24,7 +24,7 @@ export type TemplatesDirectPageProps = {
};
export default async function TemplatesDirectPage({ params }: TemplatesDirectPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { token } = params;

View File

@ -19,7 +19,7 @@ type RecipientLayoutProps = {
* Such as direct template access, or signing.
*/
export default async function RecipientLayout({ children }: RecipientLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user, session } = await getServerComponentSession();

View File

@ -8,8 +8,8 @@ export type SigningLayoutProps = {
children: React.ReactNode;
};
export default function SigningLayout({ children }: SigningLayoutProps) {
setupI18nSSR();
export default async function SigningLayout({ children }: SigningLayoutProps) {
await setupI18nSSR();
return (
<div>

View File

@ -40,7 +40,7 @@ export type CompletedSigningPageProps = {
export default async function CompletedSigningPage({
params: { token },
}: CompletedSigningPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -13,7 +13,7 @@ export type SigningLayoutProps = {
};
export default async function SigningLayout({ children }: SigningLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { user, session } = await getServerComponentSession();

View File

@ -31,7 +31,7 @@ export type SigningPageProps = {
};
export default async function SigningPage({ params: { token } }: SigningPageProps) {
setupI18nSSR();
await setupI18nSSR();
if (!token) {
return notFound();

View File

@ -21,7 +21,7 @@ type WaitingForTurnToSignPageProps = {
export default async function WaitingForTurnToSignPage({
params: { token },
}: WaitingForTurnToSignPageProps) {
setupI18nSSR();
await setupI18nSSR();
if (!token) {
return notFound();

View File

@ -12,7 +12,7 @@ export type DocumentPageProps = {
};
export default async function TeamsDocumentEditPage({ params }: DocumentPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -12,7 +12,7 @@ export type TeamDocumentsLogsPageProps = {
};
export default async function TeamsDocumentsLogsPage({ params }: TeamDocumentsLogsPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -12,7 +12,7 @@ export type DocumentPageProps = {
};
export default async function DocumentPage({ params }: DocumentPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -16,7 +16,7 @@ export default async function TeamsDocumentPage({
params,
searchParams = {},
}: TeamsDocumentPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -27,7 +27,7 @@ export default async function AuthenticatedTeamsLayout({
children,
params,
}: AuthenticatedTeamsLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const { session, user } = await getServerComponentSession();

View File

@ -21,7 +21,7 @@ export type TeamsSettingsBillingPageProps = {
};
export default async function TeamsSettingBillingPage({ params }: TeamsSettingsBillingPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();

View File

@ -24,7 +24,7 @@ export default async function TeamsSettingsLayout({
children,
params: { teamUrl },
}: TeamSettingsLayoutProps) {
setupI18nSSR();
await setupI18nSSR();
const session = await getRequiredServerComponentSession();

View File

@ -16,7 +16,7 @@ export type TeamsSettingsMembersPageProps = {
};
export default async function TeamsSettingsMembersPage({ params }: TeamsSettingsMembersPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { _ } = useLingui();
const { teamUrl } = params;

View File

@ -28,7 +28,7 @@ export type TeamsSettingsPageProps = {
};
export default async function TeamsSettingsPage({ params }: TeamsSettingsPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -14,7 +14,7 @@ export type TeamsSettingsPublicProfilePageProps = {
export default async function TeamsSettingsPublicProfilePage({
params,
}: TeamsSettingsPublicProfilePageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -21,7 +21,7 @@ type ApiTokensPageProps = {
};
export default async function ApiTokensPage({ params }: ApiTokensPageProps) {
const { i18n } = setupI18nSSR();
const { i18n } = await setupI18nSSR();
const { teamUrl } = params;

View File

@ -14,7 +14,7 @@ type TeamTemplatePageProps = {
};
export default async function TeamTemplatePage({ params }: TeamTemplatePageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -18,7 +18,7 @@ export default async function TeamTemplatesPage({
searchParams = {},
params,
}: TeamTemplatesPageProps) {
setupI18nSSR();
await setupI18nSSR();
const { teamUrl } = params;

View File

@ -7,8 +7,8 @@ import { Button } from '@documenso/ui/primitives/button';
const SUPPORT_EMAIL = 'support@documenso.com';
export default function SignatureDisclosure() {
setupI18nSSR();
export default async function SignatureDisclosure() {
await setupI18nSSR();
return (
<div>

View File

@ -10,8 +10,8 @@ export const metadata: Metadata = {
title: 'Forgot password',
};
export default function ForgotPasswordPage() {
setupI18nSSR();
export default async function ForgotPasswordPage() {
await setupI18nSSR();
return (
<div className="w-screen max-w-lg px-4">

View File

@ -11,8 +11,8 @@ export const metadata: Metadata = {
title: 'Forgot Password',
};
export default function ForgotPasswordPage() {
setupI18nSSR();
export default async function ForgotPasswordPage() {
await setupI18nSSR();
return (
<div className="w-screen max-w-lg px-4">

View File

@ -9,8 +9,8 @@ type UnauthenticatedLayoutProps = {
children: React.ReactNode;
};
export default function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
setupI18nSSR();
export default async function UnauthenticatedLayout({ children }: UnauthenticatedLayoutProps) {
await setupI18nSSR();
return (
<main className="relative flex min-h-screen flex-col items-center justify-center overflow-hidden px-4 py-12 md:p-12 lg:p-24">

View File

@ -15,7 +15,7 @@ type ResetPasswordPageProps = {
};
export default async function ResetPasswordPage({ params: { token } }: ResetPasswordPageProps) {
setupI18nSSR();
await setupI18nSSR();
const isValid = await getResetTokenValidity({ token });

View File

@ -10,8 +10,8 @@ export const metadata: Metadata = {
title: 'Reset Password',
};
export default function ResetPasswordPage() {
setupI18nSSR();
export default async function ResetPasswordPage() {
await setupI18nSSR();
return (
<div className="w-screen max-w-lg px-4">

View File

@ -17,8 +17,8 @@ export const metadata: Metadata = {
title: 'Sign In',
};
export default function SignInPage() {
setupI18nSSR();
export default async function SignInPage() {
await setupI18nSSR();
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');

View File

@ -12,8 +12,8 @@ export const metadata: Metadata = {
title: 'Sign Up',
};
export default function SignUpPage() {
setupI18nSSR();
export default async function SignUpPage() {
await setupI18nSSR();
const NEXT_PUBLIC_DISABLE_SIGNUP = env('NEXT_PUBLIC_DISABLE_SIGNUP');

View File

@ -21,7 +21,7 @@ type DeclineInvitationPageProps = {
export default async function DeclineInvitationPage({
params: { token },
}: DeclineInvitationPageProps) {
setupI18nSSR();
await setupI18nSSR();
const session = await getServerComponentSession();

View File

@ -21,7 +21,7 @@ type AcceptInvitationPageProps = {
export default async function AcceptInvitationPage({
params: { token },
}: AcceptInvitationPageProps) {
setupI18nSSR();
await setupI18nSSR();
const session = await getServerComponentSession();

View File

@ -14,7 +14,7 @@ type VerifyTeamEmailPageProps = {
};
export default async function VerifyTeamEmailPage({ params: { token } }: VerifyTeamEmailPageProps) {
setupI18nSSR();
await setupI18nSSR();
const teamEmailVerification = await prisma.teamEmailVerification.findUnique({
where: {

View File

@ -17,7 +17,7 @@ type VerifyTeamTransferPage = {
export default async function VerifyTeamTransferPage({
params: { token },
}: VerifyTeamTransferPage) {
setupI18nSSR();
await setupI18nSSR();
const teamTransferVerification = await prisma.teamTransferVerification.findUnique({
where: {

View File

@ -5,8 +5,8 @@ import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
import { SendConfirmationEmailForm } from '~/components/forms/send-confirmation-email';
export default function UnverifiedAccount() {
setupI18nSSR();
export default async function UnverifiedAccount() {
await setupI18nSSR();
return (
<div className="w-screen max-w-lg px-4">

View File

@ -23,7 +23,7 @@ export type PageProps = {
};
export default async function VerifyEmailPage({ params: { token } }: PageProps) {
setupI18nSSR();
await setupI18nSSR();
if (!token) {
return (

View File

@ -11,8 +11,8 @@ export const metadata: Metadata = {
title: 'Verify Email',
};
export default function EmailVerificationWithoutTokenPage() {
setupI18nSSR();
export default async function EmailVerificationWithoutTokenPage() {
await setupI18nSSR();
return (
<div className="w-screen max-w-lg px-4">

View File

@ -56,7 +56,7 @@ export function generateMetadata() {
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const flags = await getServerComponentAllFlags();
const { i18n, lang, locales } = setupI18nSSR();
const { i18n, lang, locales } = await setupI18nSSR();
return (
<html

View File

@ -9,7 +9,7 @@ import { Button } from '@documenso/ui/primitives/button';
import NotFoundPartial from '~/components/partials/not-found';
export default async function NotFound() {
setupI18nSSR();
await setupI18nSSR();
const { session } = await getServerComponentSession();

View File

@ -1,7 +1,7 @@
import { setupI18nSSR } from '@documenso/lib/client-only/providers/i18n.server';
export default function DashboardPage() {
setupI18nSSR();
export default async function DashboardPage() {
await setupI18nSSR();
return <div>Documenso</div>;
}