Merge pull request #8 from MkSaaSHQ/email

support email with intl
This commit is contained in:
javayhu 2025-03-08 12:19:09 +08:00 committed by GitHub
commit 6e07225639
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 721 additions and 650 deletions

View File

@ -1,61 +0,0 @@
/**
* https://demo.react.email/preview/welcome/stripe-welcome
*/
export const main = {
fontFamily: '-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
};
export const container = {
};
export const box = {
padding: "8px",
};
export const hr = {
borderColor: "#e6ebf1",
margin: "20px 0",
};
export const paragraph = {
color: "#525f7f",
fontSize: "16px",
lineHeight: "24px",
textAlign: "left" as const,
};
export const anchor = {
color: "#556cd6",
};
export const button = {
backgroundColor: "#656ee8",
borderRadius: "5px",
color: "#fff",
fontSize: "16px",
fontWeight: "bold",
textDecoration: "none",
textAlign: "center" as const,
display: "block",
width: "100%",
padding: "10px",
};
export const footer = {
width: "100%",
color: "#8898aa",
fontSize: "12px",
lineHeight: "16px",
display: "table",
tableLayout: "fixed" as const,
};
export const footerLeft = {
display: "table-cell",
textAlign: "left" as const,
};
export const footerRight = {
display: "table-cell",
textAlign: "right" as const,
};

View File

@ -1,91 +0,0 @@
import { siteConfig } from "@/config/site";
import {
Body,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import {
anchor,
box,
container,
footer,
footerLeft,
footerRight,
hr,
main,
paragraph,
} from "./email-formats";
import { getBaseUrl } from "@/lib/urls/get-base-url";
/**
* email for newsletter welcome
*/
export const NewsletterWelcomeEmail = ({ email }: { email: string }) => {
const unsubscribeUrl = `${getBaseUrl()}/unsubscribe?email=${encodeURIComponent(email)}`;
return (
<Html>
<Head />
<Preview>Welcome to {siteConfig.name}!</Preview>
<Body style={main}>
<Container style={container}>
<Section style={box}>
<Img
src={`${getBaseUrl()}/logo.png`}
width="32"
height="32"
alt="Logo"
/>
<Hr style={hr} />
<Text style={paragraph}>
Welcome to our community!
</Text>
<Text style={paragraph}>
We value your participation and feedback. Please don't hesitate to
reach out to us if you have any questions or suggestions.
</Text>
<Text style={paragraph}>
Thanks, <br />
The{" "}
<Link style={anchor} href={getBaseUrl()}>
{siteConfig.name}
</Link>{" "}
team
</Text>
<Hr style={hr} />
<Text style={footer}>
<span style={footerLeft}>
&copy; {new Date().getFullYear()}
&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
</span>
</Text>
<Text style={footer}>
<span>
If you wish to unsubscribe, please{" "}
<Link style={anchor} href={unsubscribeUrl} target="_blank">
click here
</Link>
.
</span>
</Text>
</Section>
</Container>
</Body>
</Html>
);
};
NewsletterWelcomeEmail.PreviewProps = {
email: "support@mksaas.com",
};
export default NewsletterWelcomeEmail;

View File

@ -1,101 +0,0 @@
import { siteConfig } from "@/config/site";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import {
anchor,
box,
button,
container,
footer,
footerLeft,
footerRight,
hr,
main,
paragraph,
} from "./email-formats";
import { getBaseUrl } from "@/lib/urls/get-base-url";
interface ResetPasswordEmailProps {
userName?: string;
resetLink?: string;
}
/**
* email for reset password
*/
export const ResetPasswordEmail = ({
userName,
resetLink,
}: ResetPasswordEmailProps) => {
return (
<Html>
<Head />
<Preview>Reset your password</Preview>
<Body style={main}>
<Container style={container}>
<Section style={box}>
<Img
src={`${getBaseUrl()}/logo.png`}
width="32"
height="32"
alt="Logo"
/>
<Hr style={hr} />
<Text style={paragraph}>Hi {userName},</Text>
<Text style={paragraph}>
Someone recently requested a password change for your account. If
this was you, you can set a new password here:
</Text>
<Button style={button} href={resetLink}>
Reset password
</Button>
<Hr style={hr} />
<Text style={paragraph}>
If you don&apos;t want to change your password or didn&apos;t
request this, just ignore and delete this message.
</Text>
<Text style={paragraph}>
To keep your account secure, please don&apos;t forward this email
to anyone.
</Text>
<Text style={paragraph}>
Thanks, <br />
The{" "}
<Link style={anchor} href={getBaseUrl()}>
{siteConfig.name}
</Link>{" "}
team
</Text>
<Hr style={hr} />
<Text style={footer}>
<span style={footerLeft}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
</span>
</Text>
</Section>
</Container>
</Body>
</Html>
);
};
ResetPasswordEmail.PreviewProps = {
userName: "Mksaas",
resetLink: "https://demo.mksaas.com",
} as ResetPasswordEmailProps;
export default ResetPasswordEmail;

View File

@ -1,93 +0,0 @@
import { siteConfig } from "@/config/site";
import {
Body,
Button,
Container,
Head,
Hr,
Html,
Img,
Link,
Preview,
Section,
Text,
} from "@react-email/components";
import {
anchor,
box,
button,
container,
footer,
footerLeft,
footerRight,
hr,
main,
paragraph,
} from "./email-formats";
import { getBaseUrl } from "@/lib/urls/get-base-url";
interface VerifyEmailProps {
confirmLink?: string;
}
/**
* email for verify email
*/
export const VerifyEmail = ({ confirmLink }: VerifyEmailProps) => {
return (
<Html>
<Head />
<Preview>Confirm your email address</Preview>
<Body style={main}>
<Container style={container}>
<Section style={box}>
<Img
src={`${getBaseUrl()}/logo.png`}
width="32"
height="32"
alt="Logo"
/>
<Hr style={hr} />
<Text style={paragraph}>Confirm your email address</Text>
<Text style={paragraph}>
Thanks for starting the new account creation process. We want to
make sure it's really you. Please click the confirmation link to
continue.
</Text>
<Button style={button} href={confirmLink}>
Confirm Email
</Button>
<Hr style={hr} />
<Text style={paragraph}>
If you don&apos;t want to create an account, you can ignore this
message.
</Text>
<Text style={paragraph}>
Thanks, <br />
The{" "}
<Link style={anchor} href={getBaseUrl()}>
{siteConfig.name}
</Link>{" "}
team
</Text>
<Hr style={hr} />
<Text style={footer}>
<span style={footerLeft}>
&copy; {new Date().getFullYear()}
&nbsp;&nbsp; All Rights Reserved.
</span>
<span style={footerRight}>
</span>
</Text>
</Section>
</Container>
</Body>
</Html>
);
};
VerifyEmail.PreviewProps = {
confirmLink: "https://demo.mksaas.com",
} as VerifyEmailProps;
export default VerifyEmail;

View File

@ -66,5 +66,27 @@
"tableOfContents": "Table of Contents",
"all": "All",
"noPostsFound": "No posts found"
}
},
"mail": {
"common": {
"team": "{name} Team",
"copyright": "Copyright {year} All Rights Reserved."
},
"verifyEmail": {
"title": "Hi, {name}.",
"body": "Please click the link below to verify your email address.",
"confirmEmail": "Confirm email",
"subject": "Verify your email"
},
"forgotPassword": {
"title": "Hi, {name}.",
"body": "Please click the link below to reset your password.",
"resetPassword": "Reset password",
"subject": "Reset your password"
},
"subscribeNewsletter": {
"body": "Thank you for subscribing to the newsletter. We will keep you updated with the latest news and updates.",
"subject": "Thanks for subscribing"
}
}
}

View File

@ -66,5 +66,27 @@
"tableOfContents": "目录",
"all": "全部",
"noPostsFound": "没有找到文章"
}
},
"mail": {
"common": {
"team": "{name} 团队",
"copyright": "版权所有 {year}"
},
"verifyEmail": {
"title": "你好, {name}.",
"body": "请点击下面的链接验证您的邮箱地址。",
"confirmEmail": "验证邮箱",
"subject": "验证您的邮箱"
},
"forgotPassword": {
"title": "你好, {name}.",
"body": "请点击下面的链接重置您的密码。",
"resetPassword": "重置密码",
"subject": "重置您的密码"
},
"subscribeNewsletter": {
"body": "感谢您订阅我们的邮件列表,我们将持续为您带来最新的新闻和更新。",
"subject": "感谢您的订阅"
}
}
}

View File

@ -7,7 +7,7 @@
"build": "content-collections build && next build",
"start": "next start",
"lint": "next lint",
"email": "email dev --dir emails --port 3333"
"email": "email dev --dir src/mail/emails --port 3333"
},
"dependencies": {
"@ai-sdk/openai": "^1.1.13",
@ -31,6 +31,7 @@
"@radix-ui/react-toggle-group": "^1.1.2",
"@radix-ui/react-tooltip": "^1.1.8",
"@react-email/components": "0.0.33",
"@react-email/render": "1.0.5",
"@stripe/stripe-js": "^5.6.0",
"@types/canvas-confetti": "^1.9.0",
"ai": "^4.1.45",
@ -38,6 +39,7 @@
"canvas-confetti": "^1.9.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cookie": "^1.0.2",
"date-fns": "^4.1.0",
"deepmerge": "^4.3.1",
"dotenv": "^16.4.7",
@ -69,6 +71,7 @@
"tailwind-merge": "^3.0.1",
"tailwindcss-animate": "^1.0.7",
"unist-util-visit": "^5.0.0",
"use-intl": "^3.26.5",
"vaul": "^1.1.2",
"zod": "^3.24.2",
"zustand": "^5.0.3"

15
pnpm-lock.yaml generated
View File

@ -71,6 +71,9 @@ importers:
'@react-email/components':
specifier: 0.0.33
version: 0.0.33(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@react-email/render':
specifier: 1.0.5
version: 1.0.5(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@stripe/stripe-js':
specifier: ^5.6.0
version: 5.6.0
@ -92,6 +95,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
cookie:
specifier: ^1.0.2
version: 1.0.2
date-fns:
specifier: ^4.1.0
version: 4.1.0
@ -185,6 +191,9 @@ importers:
unist-util-visit:
specifier: ^5.0.0
version: 5.0.0
use-intl:
specifier: ^3.26.5
version: 3.26.5(react@19.0.0)
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -2573,6 +2582,10 @@ packages:
resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
engines: {node: '>= 0.6'}
cookie@1.0.2:
resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==}
engines: {node: '>=18'}
cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
@ -6498,6 +6511,8 @@ snapshots:
cookie@0.7.2: {}
cookie@1.0.2: {}
cors@2.8.5:
dependencies:
object-assign: 4.1.1

View File

@ -64,7 +64,7 @@ export default async function BlogCategoryPage({
if (!post.published || post.locale !== locale) {
return false;
}
// Check if any of the post's categories match the current category slug
return post.categories.some(category => category && category.slug === slug);
}
@ -80,12 +80,7 @@ export default async function BlogCategoryPage({
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / POSTS_PER_PAGE);
console.log(
"BlogCategoryPage, totalCount",
totalCount,
", totalPages",
totalPages,
);
// console.log("BlogCategoryPage, totalCount", totalCount, ", totalPages", totalPages);
return (
<div>

View File

@ -45,12 +45,7 @@ export default async function BlogPage({
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / POSTS_PER_PAGE);
console.log(
"BlogPage, totalCount",
totalCount,
", totalPages",
totalPages,
);
// console.log("BlogPage, totalCount", totalCount, ", totalPages", totalPages,);
return (
<div>

View File

@ -1,7 +1,7 @@
import AllPostsButton from '@/components/blog/all-posts-button';
import { BlogToc } from '@/components/blog/blog-toc';
import { Mdx } from '@/components/marketing/blog/mdx-component';
import { Link } from '@/i18n/navigation';
import { LocaleLink } from '@/i18n/navigation';
import { getTableOfContents } from '@/lib/toc';
import { getBaseUrl } from '@/lib/urls/get-base-url';
import { getLocaleDate } from '@/lib/utils';
@ -10,9 +10,9 @@ import { allPosts } from 'content-collections';
import type { Metadata } from 'next';
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getTranslations } from 'next-intl/server';
import '@/styles/mdx.css';
import { getTranslations } from 'next-intl/server';
/**
* Gets the blog post from the params
@ -162,15 +162,12 @@ export default async function BlogPostPage(props: NextPageProps) {
{post.categories?.filter(Boolean).map((category) => (
category && (
<li key={category.slug}>
<Link
href={{
pathname: "/blog/category/[slug]",
params: { slug: category.slug }
}}
<LocaleLink
href={`/blog/category/${category.slug}`}
className="text-sm link-underline"
>
{category.name}
</Link>
</LocaleLink>
</li>
)
))}

View File

@ -1,6 +1,6 @@
import { LoginForm } from "@/components/auth/login-form";
import { siteConfig } from "@/config/site";
import { Link } from "@/i18n/navigation";
import { LocaleLink } from "@/i18n/navigation";
import { constructMetadata } from "@/lib/metadata";
import { Routes } from "@/routes";
import { useTranslations } from "next-intl";
@ -11,9 +11,6 @@ export const metadata = constructMetadata({
canonicalUrl: `${siteConfig.url}${Routes.Login}`,
});
/**
* TODO: href={Routes.TermsOfService as any}
*/
const LoginPage = () => {
const t = useTranslations("AuthPage.login");
@ -22,19 +19,19 @@ const LoginPage = () => {
<LoginForm />
<div className="text-balance text-center text-xs text-muted-foreground">
{t("byClickingContinue")}
<Link
href={Routes.TermsOfService as any}
<LocaleLink
href={Routes.TermsOfService}
className="underline underline-offset-4 hover:text-primary"
>
{t("termsOfService")}
</Link>{" "}
</LocaleLink>{" "}
{t("and")}{" "}
<Link
href={Routes.PrivacyPolicy as any}
<LocaleLink
href={Routes.PrivacyPolicy}
className="underline underline-offset-4 hover:text-primary"
>
{t("privacyPolicy")}
</Link>
</LocaleLink>
</div>
</div>
);

View File

@ -1,7 +1,7 @@
"use client";
import {lazy} from "react";
import { lazy } from "react";
/**
* Move error content to a separate chunk and load it only when needed
*

View File

@ -12,6 +12,7 @@ import {
import { cn } from "@/lib/utils";
import Link from "next/link";
import { Logo } from "@/components/logo";
import { LocaleLink } from "@/i18n/navigation";
interface AuthCardProps {
children: React.ReactNode;
@ -33,9 +34,9 @@ export const AuthCard = ({
return (
<Card className={cn("shadow-sm border border-border", className)}>
<CardHeader className="items-center">
<Link href="/" prefetch={false}>
<LocaleLink href="/" prefetch={false}>
<Logo className="mb-2" />
</Link>
</LocaleLink>
<CardDescription>{headerLabel}</CardDescription>
</CardHeader>
<CardContent>

View File

@ -1,9 +1,8 @@
"use client";
import { buttonVariants } from "@/components/ui/button";
import { LocaleLink } from "@/i18n/navigation";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/navigation";
import { ComponentProps } from "react";
interface BottomButtonProps {
href: string
@ -12,14 +11,14 @@ interface BottomButtonProps {
export const BottomButton = ({ href, label }: BottomButtonProps) => {
return (
<Link
href={href as ComponentProps<typeof Link>["href"]}
<LocaleLink
href={href}
className={cn(
buttonVariants({ variant: "link", size: "sm" }),
"font-normal w-full text-muted-foreground hover:underline underline-offset-4 hover:text-primary"
)}
>
{label}
</Link>
</LocaleLink>
);
};

View File

@ -38,26 +38,27 @@ export const ForgotPasswordForm = ({ className }: { className?: string }) => {
});
const onSubmit = async (values: z.infer<typeof ForgotPasswordSchema>) => {
console.log("forgotPassword, values:", values);
const { data, error } = await authClient.forgetPassword({
email: values.email,
redirectTo: `${Routes.ResetPassword}`,
}, {
onRequest: (ctx) => {
// console.log("forgotPassword, request:", ctx.url);
console.log("forgotPassword, request:", ctx.url);
setIsPending(true);
setError("");
setSuccess("");
},
onResponse: (ctx) => {
// console.log("forgotPassword, response:", ctx.response);
console.log("forgotPassword, response:", ctx.response);
setIsPending(false);
},
onSuccess: (ctx) => {
// console.log("forgotPassword, success:", ctx.data);
console.log("forgotPassword, success:", ctx.data);
setSuccess(t("checkEmail"));
},
onError: (ctx) => {
console.log("forgotPassword, error:", ctx.error);
console.error("forgotPassword, error:", ctx.error);
setError(ctx.error.message);
},
});

View File

@ -14,7 +14,7 @@ import {
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Link } from "@/i18n/navigation";
import { LocaleLink } from "@/i18n/navigation";
import { authClient } from "@/lib/auth-client";
import { LoginSchema } from "@/lib/schemas";
import { cn } from "@/lib/utils";
@ -69,7 +69,7 @@ export const LoginForm = ({ className }: { className?: string }) => {
// router.push(callbackUrl || "/dashboard");
},
onError: (ctx) => {
console.log("login, error:", ctx.error);
console.error("login, error:", ctx.error);
setError(ctx.error.message);
},
});
@ -117,12 +117,12 @@ export const LoginForm = ({ className }: { className?: string }) => {
asChild
className="px-0 font-normal text-muted-foreground"
>
<Link
<LocaleLink
href={`${Routes.ForgotPassword}`}
className="text-xs hover:underline hover:underline-offset-4 hover:text-primary"
>
{t("forgotPassword")}
</Link>
</LocaleLink>
</Button>
</div>
<FormControl>

View File

@ -70,7 +70,7 @@ export const RegisterForm = () => {
},
onError: (ctx) => {
// sign up fail, display the error message
console.log("register, error:", ctx.error.message);
console.error("register, error:", ctx.error);
setError(ctx.error.message);
},
});

View File

@ -24,6 +24,9 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import type * as z from "zod";
/**
* https://www.better-auth.com/docs/authentication/email-password#forget-password
*/
export const ResetPasswordForm = () => {
const searchParams = useSearchParams();
const token = searchParams.get("token");
@ -31,6 +34,13 @@ export const ResetPasswordForm = () => {
notFound();
}
// If the token is valid, the user will be redirected to this URL with the token in the query string.
// If the token is invalid, the user will be redirected to this URL with an error message in the query string ?error=invalid_token.
// TODO: check if the token is valid, show error message instead of redirecting to the 404 page
if (searchParams.get("error") === "invalid_token") {
notFound();
}
const router = useRouter();
const [error, setError] = useState<string | undefined>("");
const [success, setSuccess] = useState<string | undefined>("");
@ -65,7 +75,7 @@ export const ResetPasswordForm = () => {
router.push(`${Routes.Login}`);
},
onError: (ctx) => {
console.log("resetPassword, error:", ctx.error);
console.error("resetPassword, error:", ctx.error);
setError(ctx.error.message);
},
});

View File

@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { ArrowLeftIcon } from "lucide-react";
import { Link } from "@/i18n/navigation";
import { LocaleLink } from "@/i18n/navigation";
export default function AllPostsButton() {
return (
@ -12,13 +12,13 @@ export default function AllPostsButton() {
className="inline-flex items-center gap-2 group"
asChild
>
<Link href="/blog">
<LocaleLink href="/blog">
<ArrowLeftIcon
className="w-5 h-5
transition-transform duration-200 group-hover:-translate-x-1"
/>
<span>All Posts</span>
</Link>
</LocaleLink>
</Button>
);
}

View File

@ -2,7 +2,7 @@ import { Skeleton } from "@/components/ui/skeleton";
import { getLocaleDate } from "@/lib/utils";
import { Post } from "content-collections";
import Image from "next/image";
import { Link } from "@/i18n/navigation";
import { LocaleLink } from "@/i18n/navigation";
interface BlogCardProps {
post: Post;
@ -16,13 +16,13 @@ export default function BlogCard({ post }: BlogCardProps) {
const slugParts = post.slugAsParams.split('/');
return (
<div className="group cursor-pointer flex flex-col border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden h-full">
{/* Image container - fixed aspect ratio */}
<div className="group overflow-hidden relative aspect-[16/9] w-full">
<Link href={{
pathname: "/blog/[...slug]",
params: { slug: slugParts }
}}>
<LocaleLink
href={`/blog/${slugParts.join('/')}`}
className="block h-full"
>
<div className="group flex flex-col border border-gray-200 dark:border-gray-800 rounded-lg overflow-hidden h-full">
{/* Image container - fixed aspect ratio */}
<div className="group overflow-hidden relative aspect-[16/9] w-full">
{post.image && (
<div className="relative w-full h-full">
<Image
@ -49,18 +49,13 @@ export default function BlogCard({ post }: BlogCardProps) {
)}
</div>
)}
</Link>
</div>
</div>
{/* Post info container */}
<div className="flex flex-col justify-between p-4 flex-1">
<div>
{/* Post title */}
<h3 className="text-lg line-clamp-2 font-medium">
<Link href={{
pathname: "/blog/[...slug]",
params: { slug: slugParts }
}}>
{/* Post info container */}
<div className="flex flex-col justify-between p-4 flex-1">
<div>
{/* Post title */}
<h3 className="text-lg line-clamp-2 font-medium">
<span
className="bg-gradient-to-r from-green-200 to-green-100
bg-[length:0px_10px] bg-left-bottom bg-no-repeat
@ -72,46 +67,41 @@ export default function BlogCard({ post }: BlogCardProps) {
>
{post.title}
</span>
</Link>
</h3>
</h3>
{/* Post excerpt, hidden for now */}
<div className="mt-2">
{post.description && (
<p className="line-clamp-2 text-sm text-gray-500 dark:text-gray-400">
<Link href={{
pathname: "/blog/[...slug]",
params: { slug: slugParts }
}}>
{/* Post excerpt */}
<div className="mt-2">
{post.description && (
<p className="line-clamp-2 text-sm text-gray-500 dark:text-gray-400">
{post.description}
</Link>
</p>
)}
</div>
</div>
{/* Author and date */}
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between space-x-4 text-muted-foreground">
<div className="flex items-center gap-2">
<div className="relative h-8 w-8 flex-shrink-0">
{post?.author?.avatar && (
<Image
src={post?.author?.avatar}
alt={`avatar for ${post?.author?.name}`}
className="rounded-full object-cover border"
fill
/>
</p>
)}
</div>
<span className="truncate text-sm">{post?.author?.name}</span>
</div>
<time className="truncate text-sm" dateTime={date}>
{date}
</time>
{/* Author and date */}
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800 flex items-center justify-between space-x-4 text-muted-foreground">
<div className="flex items-center gap-2">
<div className="relative h-8 w-8 flex-shrink-0">
{post?.author?.avatar && (
<Image
src={post?.author?.avatar}
alt={`avatar for ${post?.author?.name}`}
className="rounded-full object-cover border"
fill
/>
)}
</div>
<span className="truncate text-sm">{post?.author?.name}</span>
</div>
<time className="truncate text-sm" dateTime={date}>
{date}
</time>
</div>
</div>
</div>
</div>
</LocaleLink>
);
}

View File

@ -3,7 +3,7 @@
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { cn } from "@/lib/utils";
import { Category } from "content-collections";
import { Link } from "@/i18n/navigation";
import { LocaleLink } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
@ -18,50 +18,46 @@ export function BlogCategoryListDesktop({
const t = useTranslations("BlogPage");
return (
<div>
{/* Desktop View */}
<div className="flex items-center justify-center">
<ToggleGroup
size="sm"
type="single"
value={slug || "All"}
aria-label="Toggle blog category"
className="h-9 overflow-hidden rounded-full border bg-background p-1 *:h-7 *:text-muted-foreground"
<div className="flex items-center justify-center">
<ToggleGroup
size="sm"
type="single"
value={slug || "All"}
aria-label="Toggle blog category"
className="h-9 overflow-hidden rounded-full border bg-background p-1 *:h-7 *:text-muted-foreground"
>
<ToggleGroupItem
key="All"
value="All"
className={cn(
"rounded-full px-5",
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
"hover:bg-muted hover:text-muted-foreground",
)}
aria-label={"Toggle all blog categories"}
>
<LocaleLink href={"/blog"}>
<h2>{t("all")}</h2>
</LocaleLink>
</ToggleGroupItem>
{categoryList.map((category) => (
<ToggleGroupItem
key="All"
value="All"
key={category.slug}
value={category.slug}
className={cn(
"rounded-full px-5",
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
"hover:bg-muted hover:text-muted-foreground",
)}
aria-label={"Toggle all blog categories"}
aria-label={`Toggle blog category of ${category.name}`}
>
<Link href={"/blog"}>
<h2>{t("all")}</h2>
</Link>
<LocaleLink href={`/blog/category/${category.slug}`}>
<h2>{category.name}</h2>
</LocaleLink>
</ToggleGroupItem>
{categoryList.map((category) => (
<ToggleGroupItem
key={category.slug}
value={category.slug}
className={cn(
"rounded-full px-5",
"data-[state=on]:bg-primary data-[state=on]:text-primary-foreground",
"hover:bg-muted hover:text-muted-foreground",
)}
aria-label={`Toggle blog category of ${category.name}`}
>
{/* TODO: fix as any */}
<Link href={`/blog/category/${category.slug}` as any}>
<h2>{category.name}</h2>
</Link>
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
))}
</ToggleGroup>
</div>
);
}

View File

@ -17,43 +17,43 @@ import { useTransition } from "react";
* https://x.com/asidorenko_/status/1841547623712407994
*/
export default function Error({ reset }: { reset: () => void }) {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const t = useTranslations('ErrorPage');
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8">
<Logo className="size-12" />
const router = useRouter();
const [isPending, startTransition] = useTransition();
const t = useTranslations('ErrorPage');
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-8">
<Logo className="size-12" />
<h1 className="text-2xl text-center">{t('title')}</h1>
<h1 className="text-2xl text-center">{t('title')}</h1>
<div className="flex items-center gap-4">
<Button
type="submit"
variant="default"
disabled={isPending}
onClick={() => {
startTransition(() => {
router.refresh();
reset();
});
}}
>
{isPending ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
""
)}
{t('tryAgain')}
</Button>
<div className="flex items-center gap-4">
<Button
type="submit"
variant="default"
disabled={isPending}
onClick={() => {
startTransition(() => {
router.refresh();
reset();
});
}}
>
{isPending ? (
<Loader2Icon className="mr-2 size-4 animate-spin" />
) : (
""
)}
{t('tryAgain')}
</Button>
<Button
type="submit"
variant="outline"
onClick={() => router.push("/")}
>
{t('backToHome')}
</Button>
</div>
</div>
);
<Button
type="submit"
variant="outline"
onClick={() => router.push("/")}
>
{t('backToHome')}
</Button>
</div>
</div>
);
}

View File

@ -7,7 +7,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { usePathname, useRouter } from "@/i18n/navigation";
import { useLocalePathname, useLocaleRouter } from "@/i18n/navigation";
import {
DEFAULT_LOCALE,
Locale,
@ -30,8 +30,8 @@ import { useEffect, useTransition } from "react";
* https://next-intl.dev/docs/routing/navigation#userouter
*/
export default function LocaleSelector() {
const router = useRouter();
const pathname = usePathname();
const router = useLocaleRouter();
const pathname = useLocalePathname();
const params = useParams();
const locale = useLocale();
const { currentLocale, setCurrentLocale } = useLocaleStore();

View File

@ -19,6 +19,7 @@ import * as React from 'react';
import { RemoveScroll } from 'react-remove-scroll';
import { ThemeSwitcherHorizontal } from '@/components/layout/theme-switcher-horizontal';
import LocaleSelector from '@/components/layout/locale-selector';
import { LocaleLink } from '@/i18n/navigation';
export function NavbarMobile({
className,
@ -63,10 +64,10 @@ export function NavbarMobile({
{...other}
>
{/* navbar left shows logo */}
<Link href={Routes.Root} className="flex items-center gap-2">
<LocaleLink href={Routes.Root} className="flex items-center gap-2">
<Logo />
<span className="text-xl font-semibold">{siteConfig.name}</span>
</Link>
</LocaleLink>
{/* navbar right shows menu icon */}
<Button
@ -112,7 +113,7 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
<div className="flex size-full flex-col items-start space-y-4 p-4">
{/* action buttons */}
<div className="flex w-full flex-col gap-2">
<Link
<LocaleLink
href={Routes.Login}
onClick={onLinkClicked}
className={cn(
@ -124,8 +125,8 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
)}
>
Log in
</Link>
<Link
</LocaleLink>
<LocaleLink
href={Routes.Register}
className={cn(
buttonVariants({
@ -137,7 +138,7 @@ function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
onClick={onLinkClicked}
>
Sign up
</Link>
</LocaleLink>
</div>
{/* main menu */}

View File

@ -27,6 +27,7 @@ import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ArrowUpRightIcon } from 'lucide-react';
import LocaleSelector from '@/components/layout/locale-selector';
import { LocaleLink } from '@/i18n/navigation';
interface NavBarProps {
scroll?: boolean;
@ -60,10 +61,10 @@ export function Navbar({ scroll, config }: NavBarProps) {
<nav className="hidden lg:flex">
{/* logo and name */}
<div className="flex items-center">
<a href="/" className="flex items-center space-x-2">
<LocaleLink href="/" className="flex items-center space-x-2">
<Logo />
<span className="text-xl font-semibold">{siteConfig.name}</span>
</a>
</LocaleLink>
</div>
{/* menu links */}
@ -173,9 +174,9 @@ export function Navbar({ scroll, config }: NavBarProps) {
size="sm"
asChild
>
<a href={Routes.Register}>
<LocaleLink href={Routes.Register}>
Sign up
</a>
</LocaleLink>
</Button>
</div>
)}

View File

@ -1,32 +0,0 @@
"use client";
import { Link } from "@/i18n/navigation";
import clsx from "clsx";
import { useSelectedLayoutSegment } from "next/navigation";
import { ComponentProps } from "react";
/**
* This component is used to render a link in the navigation bar.
*
* https://next-intl.dev/docs/routing/navigation#link
*/
export default function NavigationLink({
href,
...rest
}: ComponentProps<typeof Link>) {
const selectedLayoutSegment = useSelectedLayoutSegment();
const pathname = selectedLayoutSegment ? `/${selectedLayoutSegment}` : "/";
const isActive = pathname === href;
return (
<Link
aria-current={isActive ? "page" : undefined}
className={clsx(
"inline-block px-2 py-3 transition-colors",
isActive ? "text-white" : "text-gray-400 hover:text-gray-200"
)}
href={href}
{...rest}
/>
);
}

View File

@ -1,7 +1,7 @@
"use client";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/navigation";
import { LocaleLink } from "@/i18n/navigation";
interface FilterItemMobileProps {
title: string;
@ -18,8 +18,8 @@ export default function FilterItemMobile({
}: FilterItemMobileProps) {
return (
<li className="mb-1 last:mb-0">
<Link
href={href as any}
<LocaleLink
href={href}
onClick={clickAction}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm hover:bg-muted",
@ -27,7 +27,7 @@ export default function FilterItemMobile({
)}
>
{title}
</Link>
</LocaleLink>
</li>
);
}

View File

@ -15,7 +15,6 @@ export const footerConfig: FooterConfig = {
title: "Resources",
items: [
{ title: "Blog", href: Routes.Blog },
{ title: "Documentation", href: Routes.Docs },
{ title: "Changelog", href: Routes.Changelog },
{ title: "Roadmap", href: Routes.Roadmap },
],

View File

@ -1,6 +1,6 @@
import type { SiteConfig } from "@/types";
const SITE_URL = process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000";
const SITE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? "http://localhost:3000";
export const siteConfig: SiteConfig = {
name: "MkSaaS",

27
src/i18n/messages.ts Normal file
View File

@ -0,0 +1,27 @@
import deepmerge from "deepmerge";
import { routing } from "./routing";
import type messages from "../../messages/en.json";
export type Messages = typeof messages;
export const importLocale = async (locale: string): Promise<Messages> => {
return (await import(`../../messages/${locale}.json`)).default as Messages;
};
/**
* If you have incomplete messages for a given locale and would like to use messages
* from another locale as a fallback, you can merge the two accordingly.
*
* https://next-intl.dev/docs/usage/configuration#messages
*/
export const getMessagesForLocale = async (
locale: string,
): Promise<Messages> => {
const localeMessages = await importLocale(locale);
if (locale === routing.defaultLocale) {
return localeMessages;
}
const defaultLocaleMessages = await importLocale(routing.defaultLocale);
return deepmerge(defaultLocaleMessages, localeMessages);
};

View File

@ -6,5 +6,10 @@ import { routing } from "./routing";
*
* https://next-intl.dev/docs/routing/navigation
*/
export const { Link, getPathname, redirect, usePathname, useRouter } =
createNavigation(routing);
export const {
Link: LocaleLink,
getPathname: getLocalePathname,
redirect: localeRedirect,
usePathname: useLocalePathname,
useRouter: useLocaleRouter
} = createNavigation(routing);

View File

@ -1,7 +1,6 @@
import { getRequestConfig } from "next-intl/server";
import { getMessagesForLocale } from "./messages";
import { routing } from "./routing";
import deepmerge from "deepmerge";
import { AbstractIntlMessages } from "next-intl";
/**
* i18n/request.ts can be used to provide configuration for server-only code,
@ -25,9 +24,7 @@ export default getRequestConfig(async ({ requestLocale }) => {
// https://next-intl.dev/docs/usage/configuration#messages
// If you have incomplete messages for a given locale and would like to use messages
// from another locale as a fallback, you can merge the two accordingly.
const userMessages = (await import(`../../messages/${locale}.json`)).default;
const defaultMessages = (await import(`../../messages/${routing.defaultLocale}.json`)).default;
const messages = deepmerge(defaultMessages, userMessages) as AbstractIntlMessages;
const messages = await getMessagesForLocale(locale);
return {
locale,

View File

@ -7,6 +7,9 @@ export const LOCALE_LIST: Record<string, { flag: string; name: string }> = {
export const DEFAULT_LOCALE = "en";
export const LOCALES = Object.keys(LOCALE_LIST);
// The name of the cookie that is used to determine the locale
export const LOCALE_COOKIE_NAME = "NEXT_LOCALE";
/**
* Next.js internationalized routing
*
@ -20,24 +23,27 @@ export const routing = defineRouting({
// Auto detect locale
// https://next-intl.dev/docs/routing/middleware#locale-detection
localeDetection: false,
// Once a locale is detected, it will be remembered for
// future requests by being stored in the NEXT_LOCALE cookie.
localeCookie: {
name: LOCALE_COOKIE_NAME,
},
// The prefix to use for the locale in the URL
// https://next-intl.dev/docs/routing#locale-prefix
localePrefix: "as-needed",
// The pathnames for each locale
// https://next-intl.dev/docs/routing#pathnames
pathnames: {
"/": "/",
"/blog": "/blog",
"/blog/[...slug]": "/blog/[...slug]",
"/blog/category/[slug]": "/blog/category/[slug]",
// 认证相关路径
"/auth/login": "/auth/login",
"/auth/register": "/auth/register",
"/auth/forgot-password": "/auth/forgot-password",
"/auth/reset-password": "/auth/reset-password",
"/auth/error": "/auth/error",
},
//
// https://next-intl.dev/docs/routing/navigation#link
// if we set pathnames, we need to use pathname in LocaleLink
// pathnames: {
// // used in sietmap.ts
// "/": "/",
// // used in blog pages
// "/blog/[...slug]": "/blog/[...slug]",
// "/blog/category/[slug]": "/blog/category/[slug]",
// },
});
export type Pathnames = keyof typeof routing.pathnames;
// export type Pathnames = keyof typeof routing.pathnames;
export type Locale = (typeof routing.locales)[number];

View File

@ -2,7 +2,7 @@ import { createAuthClient } from "better-auth/react";
import { adminClient } from "better-auth/client/plugins";
export const authClient = createAuthClient({
baseURL: process.env.NEXT_PUBLIC_APP_URL!,
baseURL: process.env.NEXT_PUBLIC_BASE_URL!,
plugins: [
// https://www.better-auth.com/docs/plugins/admin#add-the-client-plugin
adminClient(),

View File

@ -1,14 +1,13 @@
import { siteConfig } from "@/config/site";
import db from "@/db/index";
import { account, session, user, verification } from "@/db/schema";
import { send } from "@/mail/send";
import { getLocaleFromRequest } from "@/lib/utils";
import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { user, session, account, verification } from "@/db/schema";
import { siteConfig } from "@/config/site";
import { resend } from "@/lib/mail";
import { admin } from "better-auth/plugins";
import db from "@/db/index";
import ResetPasswordEmail from "../../emails/reset-password";
import VerifyEmail from "../../emails/verify-email";
const from = process.env.BETTER_AUTH_EMAIL || "delivered@resend.dev";
const from = process.env.RESEND_FROM || "delivered@resend.dev";
export const auth = betterAuth({
appName: siteConfig.name,
@ -32,34 +31,44 @@ export const auth = betterAuth({
},
// https://www.better-auth.com/docs/concepts/session-management#session-expiration
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // 1 day (every 1 day the session expiration is updated)
updateAge: 60 * 60 * 24, // every 1 day the session expiration is updated
// https://www.better-auth.com/docs/concepts/session-management#session-freshness
freshAge: 60 * 5 // 5 minutes (the session is fresh if created within the last 5 minutes)
freshAge: 60 * 5 // the session is fresh if created within the last 5 minutes
},
emailAndPassword: {
enabled: true,
// https://www.better-auth.com/docs/concepts/email#2-require-email-verification
requireEmailVerification: true,
// https://www.better-auth.com/docs/authentication/email-password#forget-password
async sendResetPassword({ user, url }) {
await resend.emails.send({
from,
to: user.email,
subject: "Reset your password",
react: ResetPasswordEmail({ userName: user.name, resetLink: url }),
});
},
async sendResetPassword({ user, url }, request) {
const locale = getLocaleFromRequest(request);
console.log("sendResetPassword, locale:", locale);
await send({
to: user.email,
template: "forgotPassword",
context: {
url,
name: user.name,
},
locale,
});
},
},
emailVerification: {
// https://www.better-auth.com/docs/concepts/email#auto-signin-after-verification
autoSignInAfterVerification: true,
// https://www.better-auth.com/docs/authentication/email-password#require-email-verification
sendVerificationEmail: async ( { user, url, token }, request) => {
await resend.emails.send({
from,
sendVerificationEmail: async ({ user, url, token }, request) => {
const locale = getLocaleFromRequest(request);
console.log("sendVerificationEmail, locale:", locale);
await send({
to: user.email,
subject: "Confirm your email address",
react: VerifyEmail({ confirmLink: url }),
template: "verifyEmail",
context: {
url,
name: user.name,
},
locale,
});
},
},
@ -77,11 +86,11 @@ export const auth = betterAuth({
},
account: {
// https://www.better-auth.com/docs/concepts/users-accounts#account-linking
accountLinking: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github"],
},
},
trustedProviders: ["google", "github"],
},
},
plugins: [
// https://www.better-auth.com/docs/plugins/admin
admin(),

View File

@ -1,3 +0,0 @@
import { Resend } from "resend";
export const resend = new Resend(process.env.RESEND_API_KEY);

View File

@ -191,17 +191,6 @@ export const RegisterSchema = z.object({
}),
});
export const ActivateSchema = z.object({
name: z.string().min(1, {
message: "GitHub username is required",
}),
license: z.string().min(1, {
message: "License key is required",
}),
});
export type ActivateFormData = z.infer<typeof ActivateSchema>;
/**
* og image schema
*/

View File

@ -1,5 +1,7 @@
import { AppInfo } from "@/constants/app-info";
import { Locale, LOCALE_COOKIE_NAME, routing } from "@/i18n/routing";
import { type ClassValue, clsx } from "clsx";
import { parse as parseCookies } from "cookie";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
@ -61,4 +63,19 @@ export function getLocaleDate(input: string | number): string {
const month = (date.getMonth() + 1).toString().padStart(2, "0");
const day = date.getDate().toString().padStart(2, "0");
return `${year}/${month}/${day}`;
}
}
/**
* Gets the locale from a request by parsing the cookies
* If no locale is found in the cookies, returns the default locale
*
* @param request - The request to get the locale from
* @returns The locale from the request or the default locale
*/
export const getLocaleFromRequest = (request?: Request) => {
const cookies = parseCookies(request?.headers.get("cookie") ?? "");
return (
(cookies[LOCALE_COOKIE_NAME] as Locale) ??
routing.defaultLocale
);
};

View File

@ -0,0 +1,18 @@
import { Button } from "@react-email/components";
import React, { type PropsWithChildren } from "react";
export default function EmailButton({
href,
children,
}: PropsWithChildren<{
href: string;
}>) {
return (
<Button
href={href}
className="rounded-lg bg-black px-4 py-2 text-md text-white"
>
{children}
</Button>
);
}

View File

@ -0,0 +1,36 @@
import {
Container,
Font,
Head,
Html,
Section,
Tailwind,
} from "@react-email/components";
import React, { type PropsWithChildren } from "react";
/**
* Email Layout
*
* https://react.email/docs/components/tailwind
*/
export default function EmailLayout({ children }: PropsWithChildren) {
return (
<Html lang="en">
<Head>
<Font
fontFamily="Inter"
fallbackFontFamily="Arial"
fontWeight={400}
fontStyle="normal"
/>
</Head>
<Tailwind>
<Section className="bg-background p-4">
<Container className="rounded-lg bg-card p-6 text-card-foreground">
{children}
</Container>
</Section>
</Tailwind>
</Html>
);
}

View File

@ -0,0 +1,48 @@
import { siteConfig } from "@/config/site";
import EmailButton from "@/mail/components/EmailButton";
import EmailLayout from "@/mail/components/EmailLayout";
import { defaultLocale, defaultMessages } from "@/mail/messages";
import type { BaseMailProps } from "@/mail/types";
import { Text } from "@react-email/components";
import { createTranslator } from "use-intl/core";
export function ForgotPassword({
url,
name,
locale,
messages,
}: {
url: string;
name: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages,
});
return (
<EmailLayout>
<Text>{t("mail.forgotPassword.title", { name })}</Text>
<Text>{t("mail.forgotPassword.body")}</Text>
<EmailButton href={url}>
{t("mail.forgotPassword.resetPassword")}
</EmailButton>
<br /><br /><br />
<Text>{t("mail.common.team", { name: siteConfig.name })}</Text>
<Text>{t("mail.common.copyright", { year: new Date().getFullYear() })}</Text>
</EmailLayout>
);
}
ForgotPassword.PreviewProps = {
locale: defaultLocale,
messages: defaultMessages,
url: "https://mksaas.com",
name: "username",
};
export default ForgotPassword;

View File

@ -0,0 +1,31 @@
import EmailLayout from "@/mail/components/EmailLayout";
import { defaultLocale, defaultMessages } from "@/mail/messages";
import type { BaseMailProps } from "@/mail/types";
import { Heading, Text } from "@react-email/components";
import { createTranslator } from "use-intl/core";
export function SubscribeNewsletter({
locale,
messages,
}: BaseMailProps) {
const t = createTranslator({
locale,
messages,
});
return (
<EmailLayout>
<Heading className="text-xl">
{t("mail.subscribeNewsletter.subject")}
</Heading>
<Text>{t("mail.subscribeNewsletter.body")}</Text>
</EmailLayout>
);
}
SubscribeNewsletter.PreviewProps = {
locale: defaultLocale,
messages: defaultMessages,
};
export default SubscribeNewsletter;

View File

@ -0,0 +1,48 @@
import { siteConfig } from "@/config/site";
import EmailButton from "@/mail/components/EmailButton";
import EmailLayout from "@/mail/components/EmailLayout";
import { defaultLocale, defaultMessages } from "@/mail/messages";
import type { BaseMailProps } from "@/mail/types";
import { Text } from "@react-email/components";
import { createTranslator } from "use-intl/core";
export function VerifyEmail({
url,
name,
locale,
messages,
}: {
url: string;
name: string;
} & BaseMailProps) {
const t = createTranslator({
locale,
messages,
});
return (
<EmailLayout>
<Text>{t("mail.verifyEmail.title", { name })}</Text>
<Text>{t("mail.verifyEmail.body")}</Text>
<EmailButton href={url}>
{t("mail.verifyEmail.confirmEmail")}
</EmailButton>
<br /><br /><br />
<Text>{t("mail.common.team", { name: siteConfig.name })}</Text>
<Text>{t("mail.common.copyright", { year: new Date().getFullYear() })}</Text>
</EmailLayout>
);
}
VerifyEmail.PreviewProps = {
locale: defaultLocale,
messages: defaultMessages,
url: "https://mksaas.com",
name: "username",
};
export default VerifyEmail;

12
src/mail/emails/index.ts Normal file
View File

@ -0,0 +1,12 @@
import { VerifyEmail } from "./VerifyEmail";
import { ForgotPassword } from "./ForgotPassword";
import { SubscribeNewsletter } from "./SubscribeNewsletter";
/**
* list all the mail templates here
*/
export const mailTemplates = {
forgotPassword: ForgotPassword,
verifyEmail: VerifyEmail,
subscribeNewsletter: SubscribeNewsletter,
} as const;

7
src/mail/messages.ts Normal file
View File

@ -0,0 +1,7 @@
import { routing } from "@/i18n/routing";
const { defaultLocale } = routing;
export { default as defaultMessages } from "../../messages/en.json";
export { defaultLocale };

View File

@ -0,0 +1,25 @@
import { Resend } from "resend";
import { SendEmailHandler } from "@/mail/types";
export const resend = new Resend(process.env.RESEND_API_KEY);
export const sendEmail: SendEmailHandler = async ({ to, subject, html }) => {
const response = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.RESEND_API_KEY}`,
},
body: JSON.stringify({
from: process.env.RESEND_FROM,
to,
subject,
html,
}),
});
if (!response.ok) {
console.error("Error sending email", await response.json());
throw new Error("Error sending email");
}
};

107
src/mail/send.ts Normal file
View File

@ -0,0 +1,107 @@
import type { Messages } from "@/i18n/messages";
import { getMessagesForLocale } from "@/i18n/messages";
import { Locale, routing } from "@/i18n/routing";
import { mailTemplates } from "@/mail/emails";
import { sendEmail } from "@/mail/provider/resend";
import { render } from "@react-email/render";
export type Template = keyof typeof mailTemplates;
/**
* send email
*
* 1. with given template, and context
* 2. with given subject, text, and html
*/
export async function send<T extends Template>(
params: {
to: string;
locale?: Locale;
} & (
| {
template: T;
context: Omit<
Parameters<(typeof mailTemplates)[T]>[0],
"locale" | "messages"
>;
}
| {
subject: string;
text?: string;
html?: string;
}
),
) {
const { to, locale = routing.defaultLocale } = params;
console.log("send, locale:", locale);
let html: string;
let text: string;
let subject: string;
// if template is provided, get the template
// otherwise, use the subject, text, and html
if ("template" in params) {
const { template, context } = params;
const mailTemplate = await getTemplate({
template,
context,
locale,
});
subject = mailTemplate.subject;
text = mailTemplate.text;
html = mailTemplate.html;
} else {
subject = params.subject;
text = params.text ?? "";
html = params.html ?? "";
}
try {
await sendEmail({
to,
subject,
text,
html,
});
return true;
} catch (e) {
console.error("Error sending email", e);
return false;
}
}
/**
* get rendered email for given template, context, and locale
*/
export async function getTemplate<T extends Template>({
template,
context,
locale,
}: {
template: T;
context: Omit<
Parameters<(typeof mailTemplates)[T]>[0],
"locale" | "messages"
>;
locale: Locale;
}) {
const mainTemplate = mailTemplates[template];
const messages = await getMessagesForLocale(locale);
const email = mainTemplate({
...(context as any),
locale,
messages,
});
// get the subject from the messages
const subject =
"subject" in messages.mail[template as keyof Messages["mail"]]
? messages.mail[template].subject
: "";
const html = await render(email);
const text = await render(email, { plainText: true });
return { html, text, subject };
}

20
src/mail/types.ts Normal file
View File

@ -0,0 +1,20 @@
import type { Locale } from "@/i18n/routing";
import type { Messages } from "@/i18n/messages";
export interface EmailParams {
to: string;
subject: string;
text: string;
html?: string;
}
export type SendEmailHandler = (params: EmailParams) => Promise<void>;
export interface MailProvider {
send: SendEmailHandler;
}
export type BaseMailProps = {
locale: Locale;
messages: Messages;
};

View File

@ -21,6 +21,8 @@ export const config = {
// Enable redirects that add missing locales
// (e.g. `/pathnames` -> `/zh/pathnames`)
'/((?!_next|_vercel|.*\\..*).*)'
// Exclude API routes and other Next.js internal routes
// if not exclude api routes, auth routes will not work
'/((?!api|_next|_vercel|.*\\..*).*)'
]
};

View File

@ -80,16 +80,19 @@ export enum Routes {
*/
export const publicRoutes = [
"/",
"/terms(/.*)?",
"/privacy(/.*)?",
"/about(/.*)?",
"/changelog(/.*)?",
// blog
"/blog(/.*)?",
// docs
"/docs(/.*)?",
// pages
"/terms-of-service(/.*)?",
"/privacy-policy(/.*)?",
"/cookie-policy(/.*)?",
"/about(/.*)?",
"/contact(/.*)?",
"/waitlist(/.*)?",
"/changelog(/.*)?",
// unsubscribe newsletter
"/unsubscribe(/.*)?",
@ -105,9 +108,9 @@ export const publicRoutes = [
* The routes for the authentication pages
*/
export const authRoutes = [
Routes.AuthError,
Routes.Login,
Routes.Register,
Routes.AuthError,
Routes.ForgotPassword,
Routes.ResetPassword,
];

View File

@ -1,15 +1,16 @@
import { MetadataRoute } from 'next';
import { routing, Locale } from '@/i18n/routing';
import { getPathname } from '@/i18n/navigation';
import { getLocalePathname } from '@/i18n/navigation';
import { siteConfig } from './config/site';
export default function sitemap(): MetadataRoute.Sitemap {
return [...getEntries('/')];
}
type Href = Parameters<typeof getPathname>[0]['href'];
type Href = Parameters<typeof getLocalePathname>[0]['href'];
/**
* https://next-intl.dev/docs/environments/actions-metadata-route-handlers#sitemap
* https://github.com/amannn/next-intl/blob/main/examples/example-app-router/src/app/sitemap.ts
*/
function getEntries(href: Href) {
@ -24,6 +25,6 @@ function getEntries(href: Href) {
}
function getUrl(href: Href, locale: Locale) {
const pathname = getPathname({ locale, href });
const pathname = getLocalePathname({ locale, href });
return siteConfig.url + pathname;
}