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:
Giancarlo Buomprisco
2026-03-24 13:40:38 +08:00
committed by GitHub
parent 4912e402a3
commit 7ebff31475
840 changed files with 71395 additions and 20095 deletions

View File

@@ -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.

View File

@@ -1,3 +0,0 @@
import eslintConfigBase from '@kit/eslint-config/base.js';
export default eslintConfigBase;

View File

@@ -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:"
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}
}

View File

@@ -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"
}

View File

@@ -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}."
}

View File

@@ -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."
}