commit
6e07225639
@ -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,
|
||||
};
|
@ -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}>
|
||||
© {new Date().getFullYear()}
|
||||
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;
|
@ -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't want to change your password or didn't
|
||||
request this, just ignore and delete this message.
|
||||
</Text>
|
||||
<Text style={paragraph}>
|
||||
To keep your account secure, please don'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}>
|
||||
© {new Date().getFullYear()}
|
||||
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;
|
@ -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'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}>
|
||||
© {new Date().getFullYear()}
|
||||
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;
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
@ -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": "感谢您的订阅"
|
||||
}
|
||||
}
|
||||
}
|
@ -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
15
pnpm-lock.yaml
generated
@ -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
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
)
|
||||
))}
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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
|
||||
*
|
||||
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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 */}
|
||||
|
@ -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>
|
||||
)}
|
||||
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
}
|
@ -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 },
|
||||
],
|
||||
|
@ -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
27
src/i18n/messages.ts
Normal 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);
|
||||
};
|
@ -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);
|
||||
|
@ -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,
|
||||
|
@ -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];
|
||||
|
@ -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(),
|
||||
|
@ -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(),
|
||||
|
@ -1,3 +0,0 @@
|
||||
import { Resend } from "resend";
|
||||
|
||||
export const resend = new Resend(process.env.RESEND_API_KEY);
|
@ -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
|
||||
*/
|
||||
|
@ -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
|
||||
);
|
||||
};
|
18
src/mail/components/EmailButton.tsx
Normal file
18
src/mail/components/EmailButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
36
src/mail/components/EmailLayout.tsx
Normal file
36
src/mail/components/EmailLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
48
src/mail/emails/ForgotPassword.tsx
Normal file
48
src/mail/emails/ForgotPassword.tsx
Normal 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;
|
31
src/mail/emails/SubscribeNewsletter.tsx
Normal file
31
src/mail/emails/SubscribeNewsletter.tsx
Normal 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;
|
48
src/mail/emails/VerifyEmail.tsx
Normal file
48
src/mail/emails/VerifyEmail.tsx
Normal 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
12
src/mail/emails/index.ts
Normal 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
7
src/mail/messages.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { routing } from "@/i18n/routing";
|
||||
|
||||
const { defaultLocale } = routing;
|
||||
|
||||
export { default as defaultMessages } from "../../messages/en.json";
|
||||
|
||||
export { defaultLocale };
|
25
src/mail/provider/resend.ts
Normal file
25
src/mail/provider/resend.ts
Normal 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
107
src/mail/send.ts
Normal 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
20
src/mail/types.ts
Normal 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;
|
||||
};
|
@ -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|.*\\..*).*)'
|
||||
]
|
||||
};
|
||||
|
@ -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,
|
||||
];
|
||||
|
@ -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;
|
||||
}
|
Loading…
Reference in New Issue
Block a user