Next.js Supabase V3 (#463)
Version 3 of the kit: - Radix UI replaced with Base UI (using the Shadcn UI patterns) - next-intl replaces react-i18next - enhanceAction deprecated; usage moved to next-safe-action - main layout now wrapped with [locale] path segment - Teams only mode - Layout updates - Zod v4 - Next.js 16.2 - Typescript 6 - All other dependencies updated - Removed deprecated Edge CSRF - Dynamic Github Action runner
This commit is contained in:
committed by
GitHub
parent
4912e402a3
commit
7ebff31475
@@ -4,7 +4,8 @@ This package owns transactional email templates and renderers using React Email.
|
||||
|
||||
## Non-negotiables
|
||||
|
||||
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss it.
|
||||
1. New email must be added to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`) or dynamic inclusion/discovery will miss
|
||||
it.
|
||||
2. New email renderer must be exported from `src/index.ts`.
|
||||
3. Renderer contract: async function returning `{ html, subject }`.
|
||||
4. i18n namespace must match locale filename in `src/locales/<lang>/<namespace>.json`.
|
||||
@@ -19,4 +20,5 @@ This package owns transactional email templates and renderers using React Email.
|
||||
3. Export template renderer from `src/index.ts`.
|
||||
4. Add renderer to `src/registry.ts` (`EMAIL_TEMPLATE_RENDERERS`).
|
||||
|
||||
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering will miss it.
|
||||
`src/registry.ts` is required for dynamic inclusion/discovery. If not added there, dynamic template listing/rendering
|
||||
will miss it.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
@@ -1,36 +1,30 @@
|
||||
{
|
||||
"name": "@kit/email-templates",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./registry": "./src/registry.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/i18n": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
},
|
||||
"private": true,
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./registry": "./src/registry.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/react": "catalog:",
|
||||
"next-intl": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,11 +29,11 @@ export async function renderAccountDeleteEmail(props: Props) {
|
||||
namespace,
|
||||
});
|
||||
|
||||
const previewText = t(`${namespace}:previewText`, {
|
||||
const previewText = t(`previewText`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const subject = t(`${namespace}:subject`, {
|
||||
const subject = t(`subject`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
@@ -54,27 +54,27 @@ export async function renderAccountDeleteEmail(props: Props) {
|
||||
|
||||
<EmailContent>
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:hello`)}
|
||||
{t(`hello`)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph1`, {
|
||||
{t(`paragraph1`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph2`)}
|
||||
{t(`paragraph2`)}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph3`, {
|
||||
{t(`paragraph3`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:paragraph4`, {
|
||||
{t(`paragraph4`, {
|
||||
productName: props.productName,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -42,24 +42,25 @@ export async function renderInviteEmail(props: Props) {
|
||||
});
|
||||
|
||||
const previewText = `Join ${props.invitedUserEmail} on ${props.productName}`;
|
||||
const subject = t(`${namespace}:subject`);
|
||||
const subject = t(`subject`);
|
||||
|
||||
const heading = t(`${namespace}:heading`, {
|
||||
const heading = t(`heading`, {
|
||||
teamName: props.teamName,
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const hello = t(`${namespace}:hello`, {
|
||||
const hello = t(`hello`, {
|
||||
invitedUserEmail: props.invitedUserEmail,
|
||||
});
|
||||
|
||||
const mainText = t(`${namespace}:mainText`, {
|
||||
const mainText = t(`mainText`, {
|
||||
inviter: props.inviter,
|
||||
teamName: props.teamName,
|
||||
productName: props.productName,
|
||||
strong: (chunks: string) => `<strong>${chunks}</strong>`,
|
||||
});
|
||||
|
||||
const joinTeam = t(`${namespace}:joinTeam`, {
|
||||
const joinTeam = t(`joinTeam`, {
|
||||
teamName: props.teamName,
|
||||
});
|
||||
|
||||
@@ -108,7 +109,7 @@ export async function renderInviteEmail(props: Props) {
|
||||
</Section>
|
||||
|
||||
<Text className="text-[16px] leading-[24px] text-[#242424]">
|
||||
{t(`${namespace}:copyPasteLink`)}{' '}
|
||||
{t(`copyPasteLink`)}{' '}
|
||||
<Link href={props.link} className="text-blue-600 no-underline">
|
||||
{props.link}
|
||||
</Link>
|
||||
@@ -117,7 +118,7 @@ export async function renderInviteEmail(props: Props) {
|
||||
<Hr className="mx-0 my-[26px] w-full border border-solid border-[#eaeaea]" />
|
||||
|
||||
<Text className="text-[12px] leading-[24px] text-[#666666]">
|
||||
{t(`${namespace}:invitationIntendedFor`, {
|
||||
{t(`invitationIntendedFor`, {
|
||||
invitedUserEmail: props.invitedUserEmail,
|
||||
})}
|
||||
</Text>
|
||||
|
||||
@@ -32,22 +32,22 @@ export async function renderOtpEmail(props: Props) {
|
||||
namespace,
|
||||
});
|
||||
|
||||
const subject = t(`${namespace}:subject`, {
|
||||
const subject = t(`subject`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const previewText = subject;
|
||||
|
||||
const heading = t(`${namespace}:heading`, {
|
||||
const heading = t(`heading`, {
|
||||
productName: props.productName,
|
||||
});
|
||||
|
||||
const otpText = t(`${namespace}:otpText`, {
|
||||
const otpText = t(`otpText`, {
|
||||
otp: props.otp,
|
||||
});
|
||||
|
||||
const mainText = t(`${namespace}:mainText`);
|
||||
const footerText = t(`${namespace}:footerText`);
|
||||
const mainText = t(`mainText`);
|
||||
const footerText = t(`footerText`);
|
||||
|
||||
const html = await render(
|
||||
<Html>
|
||||
|
||||
@@ -1,32 +1,47 @@
|
||||
import { createI18nSettings } from '@kit/i18n';
|
||||
import { initializeServerI18n } from '@kit/i18n/server';
|
||||
import type { AbstractIntlMessages } from 'next-intl';
|
||||
import { createTranslator } from 'next-intl';
|
||||
|
||||
export function initializeEmailI18n(params: {
|
||||
export async function initializeEmailI18n(params: {
|
||||
language: string | undefined;
|
||||
namespace: string;
|
||||
}) {
|
||||
const language =
|
||||
params.language ?? process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';
|
||||
const language = params.language ?? 'en';
|
||||
|
||||
return initializeServerI18n(
|
||||
createI18nSettings({
|
||||
try {
|
||||
// Load the translation messages for the specified namespace
|
||||
const messages = (await import(
|
||||
`../locales/${language}/${params.namespace}.json`
|
||||
)) as AbstractIntlMessages;
|
||||
|
||||
// Create a translator function with the messages
|
||||
const translator = createTranslator({
|
||||
locale: language,
|
||||
messages,
|
||||
});
|
||||
|
||||
// Type-cast to make it compatible with the i18next API
|
||||
const t = translator as unknown as (
|
||||
key: string,
|
||||
values?: Record<string, unknown>,
|
||||
) => string;
|
||||
|
||||
// Return an object compatible with the i18next API
|
||||
return {
|
||||
t,
|
||||
language,
|
||||
languages: [language],
|
||||
namespaces: params.namespace,
|
||||
}),
|
||||
async (language, namespace) => {
|
||||
try {
|
||||
const data = await import(`../locales/${language}/${namespace}.json`);
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Error loading i18n file: locales/${language}/${params.namespace}.json`,
|
||||
error,
|
||||
);
|
||||
|
||||
return data as Record<string, string>;
|
||||
} catch (error) {
|
||||
console.log(
|
||||
`Error loading i18n file: locales/${language}/${namespace}.json`,
|
||||
error,
|
||||
);
|
||||
// Return a fallback translator that returns the key as-is
|
||||
const t = (key: string) => key;
|
||||
|
||||
return {};
|
||||
}
|
||||
},
|
||||
);
|
||||
return {
|
||||
t,
|
||||
language,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +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}}.",
|
||||
"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"
|
||||
}
|
||||
"paragraph3": "We thank you again for using {productName}.",
|
||||
"paragraph4": "The {productName} Team"
|
||||
}
|
||||
|
||||
@@ -1,9 +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}}",
|
||||
"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}}."
|
||||
}
|
||||
"invitationIntendedFor": "This invitation is intended for {invitedUserEmail}."
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"subject": "One-time password for {{productName}}",
|
||||
"heading": "One-time password for {{productName}}",
|
||||
"otpText": "Your one-time password is: {{otp}}",
|
||||
"subject": "One-time password for {productName}",
|
||||
"heading": "One-time password for {productName}",
|
||||
"otpText": "Your one-time password is: {otp}",
|
||||
"footerText": "Please enter the one-time password in the app to continue.",
|
||||
"mainText": "You're receiving this email because you need to verify your identity using a one-time password."
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user