Merge remote-tracking branch 'origin/main' into cloudflare

This commit is contained in:
javayhu 2025-06-08 18:25:26 +08:00
commit b6836db12d
27 changed files with 610 additions and 213 deletions

View File

@ -19,7 +19,7 @@ If you found anything that could be improved, please let me know.
- 🔥 website: [mksaas.com](https://mksaas.com) - 🔥 website: [mksaas.com](https://mksaas.com)
- 🌐 demo: [demo.mksaas.com](https://demo.mksaas.com) - 🌐 demo: [demo.mksaas.com](https://demo.mksaas.com)
- 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs) - 📚 documentation: [mksaas.com/docs](https://mksaas.com/docs)
- 🗓️ roadmap: [mksaas project](https://mksaas.link/roadmap) - 🗓️ roadmap: [mksaas roadmap](https://mksaas.link/roadmap)
- 👨‍💻 discord: [mksaas.link/discord](https://mksaas.link/discord) - 👨‍💻 discord: [mksaas.link/discord](https://mksaas.link/discord)
- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube) - 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube)
@ -27,17 +27,15 @@ If you found anything that could be improved, please let me know.
By default, you should have access to all four repositories. If you find that youre unable to access any of them, please dont hesitate to reach out to me, and Ill assist you in resolving the issue. By default, you should have access to all four repositories. If you find that youre unable to access any of them, please dont hesitate to reach out to me, and Ill assist you in resolving the issue.
- [MkSaaSHQ/mksaas-template](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com (ready) - [mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
- [MkSaaSHQ/mksaas-blog](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me (ready) - [mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
- [MkSaaSHQ/mksaas-app](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app (WIP) - [mksaas-haitang (ready)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
- [MkSaaSHQ/mksaas-haitang](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app (WIP) - [mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
## Notice ## Notice
> If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com). > If you have any questions, please [submit an issue](https://github.com/MkSaaSHQ/mksaas-template/issues/new), or contact me at [support@mksaas.com](mailto:support@mksaas.com).
> If you have any feature requests or questions or ideas to share, please [submit it in the discussions](https://github.com/MkSaaSHQ/mksaas-template/discussions).
> If you want to receive notifications whenever code changes, please click `Watch` button in the top right. > If you want to receive notifications whenever code changes, please click `Watch` button in the top right.
> When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports. > When submitting any content to the issues or discussions of the repository, please use **English** as the main Language, so that everyone can read it and help you, thank you for your supports.

View File

@ -129,3 +129,17 @@ NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN=""
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID="" NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID=""
NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID="" NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID=""
# -----------------------------------------------------------------------------
# Affiliate
# https://mksaas.com/docs/affiliate
# -----------------------------------------------------------------------------
# Affonso
# https://affonso.com/
# -----------------------------------------------------------------------------
NEXT_PUBLIC_AFFILIATE_AFFONSO_ID=""
# -----------------------------------------------------------------------------
# PromoteKit
# https://www.promotekit.com/
# -----------------------------------------------------------------------------
NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID=""

View File

@ -32,7 +32,7 @@ export async function generateMetadata({
return constructMetadata({ return constructMetadata({
title: t('title'), title: t('title'),
description: t('description'), description: t('description'),
canonicalUrl: getUrlWithLocale('/', locale), canonicalUrl: getUrlWithLocale('', locale),
}); });
} }

View File

@ -26,7 +26,7 @@ export async function generateMetadata({
return constructMetadata({ return constructMetadata({
title: category + ' | ' + t('title'), title: category + ' | ' + t('title'),
description: t('description'), description: t('description'),
canonicalUrl: getUrlWithLocale('/blocks/${category}', locale), canonicalUrl: getUrlWithLocale(`/blocks/${category}`, locale),
}); });
} }

View File

@ -1,103 +1,73 @@
import BlogGrid from '@/components/blog/blog-grid'; import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import EmptyGrid from '@/components/shared/empty-grid'; import { LOCALES } from '@/i18n/routing';
import CustomPagination from '@/components/shared/pagination'; import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { websiteConfig } from '@/config/website';
import { constructMetadata } from '@/lib/metadata'; import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls'; import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props'; import { allCategories } from 'content-collections';
import { allCategories, allPosts } from 'content-collections';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl'; import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
export async function generateMetadata({ // Generate all static params for SSG (locale + category)
params, export function generateStaticParams() {
}: { const params: { locale: string; slug: string }[] = [];
params: Promise<{ slug: string; locale: Locale }>; for (const locale of LOCALES) {
}): Promise<Metadata | undefined> { const localeCategories = allCategories.filter(
const { slug, locale } = await params; (category) => category.locale === locale
// Find category with matching slug and locale
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
);
if (!category) {
console.warn(
`generateMetadata, category not found for slug: ${slug}, locale: ${locale}`
); );
return {}; for (const category of localeCategories) {
params.push({ locale, slug: category.slug });
}
} }
return params;
}
// Generate metadata for each static category page (locale + category)
export async function generateMetadata({ params }: BlogCategoryPageProps) {
const { locale, slug } = await params;
const category = allCategories.find(
(category) => category.locale === locale && category.slug === slug
);
if (!category) {
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' }); const t = await getTranslations({ locale, namespace: 'Metadata' });
const canonicalPath = `/blog/category/${slug}`;
return constructMetadata({ return constructMetadata({
title: `${category.name} | ${t('title')}`, title: `${category.name} | ${t('title')}`,
description: category.description, description: category.description,
canonicalUrl: getUrlWithLocale('/blog/category/${slug}', locale), canonicalUrl: getUrlWithLocale(canonicalPath, locale),
}); });
} }
interface BlogCategoryPageProps {
params: Promise<{
locale: Locale;
slug: string;
}>;
}
export default async function BlogCategoryPage({ export default async function BlogCategoryPage({
params, params,
searchParams, }: BlogCategoryPageProps) {
}: NextPageProps) { const { locale, slug } = await params;
const paginationSize = websiteConfig.blog.paginationSize;
const resolvedParams = await params;
const { slug, locale } = resolvedParams;
const resolvedSearchParams = await searchParams;
const { page } = (resolvedSearchParams as { [key: string]: string }) || {};
const currentPage = page ? Number(page) : 1;
const startIndex = (currentPage - 1) * paginationSize;
const endIndex = startIndex + paginationSize;
// Find category with matching slug and locale
const category = allCategories.find( const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale (category) => category.locale === locale && category.slug === slug
); );
if (!category) {
// Filter posts by category and locale notFound();
const filteredPosts = allPosts.filter((post) => { }
if (!post.published || post.locale !== locale) { const currentPage = 1;
return false; const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
} locale,
page: currentPage,
// Check if any of the post's categories match the current category slug category: slug,
return post.categories.some(
(category) => category && category.slug === slug
);
}); });
// Sort posts by date (newest first)
const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Paginate posts
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / paginationSize);
// console.log("BlogCategoryPage, totalCount", totalCount, ", totalPages", totalPages);
return ( return (
<div> <BlogGridWithPagination
{/* when no posts are found */} posts={paginatedPosts}
{paginatedPosts.length === 0 && <EmptyGrid />} totalPages={totalPages}
routePrefix={`/blog/category/${slug}`}
{/* when posts are found */} />
{paginatedPosts.length > 0 && (
<div>
<BlogGrid posts={paginatedPosts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePreix={`/${locale}/blog/category/${resolvedParams.slug}`}
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
); );
} }

View File

@ -0,0 +1,84 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { allCategories, allPosts } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
// Generate all static params for SSG (locale + category + pagination)
export function generateStaticParams() {
const params: { locale: string; slug: string; page: string }[] = [];
for (const locale of LOCALES) {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
for (const category of localeCategories) {
const totalPages = Math.ceil(
allPosts.filter(
(post) =>
post.locale === locale &&
post.categories.some((cat) => cat && cat.slug === category.slug)
).length / websiteConfig.blog.paginationSize
);
for (let page = 2; page <= totalPages; page++) {
params.push({ locale, slug: category.slug, page: String(page) });
}
}
}
return params;
}
// Generate metadata for each static category page (locale + category + pagination)
export async function generateMetadata({ params }: BlogCategoryPageProps) {
const { locale, slug, page } = await params;
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
);
if (!category) {
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' });
const canonicalPath = `/blog/category/${slug}/page/${page}`;
return constructMetadata({
title: `${category.name} | ${t('title')}`,
description: category.description,
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
});
}
interface BlogCategoryPageProps {
params: Promise<{
locale: Locale;
slug: string;
page: string;
}>;
}
export default async function BlogCategoryPage({
params,
}: BlogCategoryPageProps) {
const { locale, slug, page } = await params;
const currentPage = Number(page);
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
);
if (!category) {
notFound();
}
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
category: slug,
});
return (
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={`/blog/category/${slug}`}
/>
);
}

View File

@ -1,84 +1,45 @@
import BlogGrid from '@/components/blog/blog-grid'; import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import EmptyGrid from '@/components/shared/empty-grid'; import { LOCALES } from '@/i18n/routing';
import CustomPagination from '@/components/shared/pagination'; import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { websiteConfig } from '@/config/website';
import { constructMetadata } from '@/lib/metadata'; import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls'; import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import { allPosts } from 'content-collections';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl'; import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
export async function generateMetadata({ export function generateStaticParams() {
params, return LOCALES.map((locale) => ({ locale }));
}: { }
params: Promise<{ locale: Locale }>;
}): Promise<Metadata | undefined> { export async function generateMetadata({ params }: BlogPageProps) {
const { locale } = await params; const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' }); const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' }); const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = '/blog';
return constructMetadata({ return constructMetadata({
title: pt('title') + ' | ' + t('title'), title: `${pt('title')} | ${t('title')}`,
description: pt('description'), description: pt('description'),
canonicalUrl: getUrlWithLocale('/blog', locale), canonicalUrl: getUrlWithLocale(canonicalPath, locale),
}); });
} }
export default async function BlogPage({ interface BlogPageProps {
params, params: Promise<{
searchParams, locale: Locale;
}: NextPageProps) { }>;
const paginationSize = websiteConfig.blog.paginationSize; }
const resolvedParams = await params;
const { locale } = resolvedParams;
const resolvedSearchParams = await searchParams;
const { page } = (resolvedSearchParams as { [key: string]: string }) || {};
const currentPage = page ? Number(page) : 1;
const startIndex = (currentPage - 1) * paginationSize;
const endIndex = startIndex + paginationSize;
// Filter posts by locale
const localePosts = allPosts.filter(
(post) => post.locale === locale && post.published
);
// If no posts found for the current locale, show all published posts
const filteredPosts =
localePosts.length > 0
? localePosts
: allPosts.filter((post) => post.published);
// Sort posts by date (newest first)
const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Paginate posts
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalCount = filteredPosts.length;
const totalPages = Math.ceil(totalCount / paginationSize);
// console.log("BlogPage, totalCount", totalCount, ", totalPages", totalPages,);
export default async function BlogPage({ params }: BlogPageProps) {
const { locale } = await params;
const currentPage = 1;
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
});
return ( return (
<div> <BlogGridWithPagination
{/* when no posts are found */} posts={paginatedPosts}
{paginatedPosts.length === 0 && <EmptyGrid />} totalPages={totalPages}
routePrefix={'/blog'}
{/* when posts are found */} />
{paginatedPosts.length > 0 && (
<div>
<BlogGrid posts={paginatedPosts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePreix={`/${locale}/blog`}
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
); );
} }

View File

@ -0,0 +1,65 @@
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { websiteConfig } from '@/config/website';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import { allPosts } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
export function generateStaticParams() {
const paginationSize = websiteConfig.blog.paginationSize;
const params: { locale: string; page: string }[] = [];
for (const locale of LOCALES) {
const publishedPosts = allPosts.filter(
(post) => post.published && post.locale === locale
);
const totalPages = Math.max(
1,
Math.ceil(publishedPosts.length / paginationSize)
);
for (let pageNumber = 2; pageNumber <= totalPages; pageNumber++) {
params.push({
locale,
page: String(pageNumber),
});
}
}
return params;
}
export async function generateMetadata({ params }: BlogListPageProps) {
const { locale, page } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = `/blog/page/${page}`;
return constructMetadata({
title: `${pt('title')} | ${t('title')}`,
description: pt('description'),
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
});
}
interface BlogListPageProps {
params: Promise<{
locale: Locale;
page: string;
}>;
}
export default async function BlogListPage({ params }: BlogListPageProps) {
const { page, locale } = await params;
const currentPage = Number(page);
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
});
return (
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={'/blog'}
/>
);
}

View File

@ -5,11 +5,11 @@ import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import { CustomMDXContent } from '@/components/shared/custom-mdx-content'; import { CustomMDXContent } from '@/components/shared/custom-mdx-content';
import { websiteConfig } from '@/config/website'; import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation'; import { LocaleLink } from '@/i18n/navigation';
import { LOCALES } from '@/i18n/routing';
import { getTableOfContents } from '@/lib/blog/toc'; import { getTableOfContents } from '@/lib/blog/toc';
import { formatDate } from '@/lib/formatter'; import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata'; import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls'; import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import { type Post, allPosts } from 'content-collections'; import { type Post, allPosts } from 'content-collections';
import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react'; import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
@ -22,7 +22,8 @@ import '@/styles/mdx.css';
/** /**
* Gets the blog post from the params * Gets the blog post from the params
* @param props - The props of the page * @param slug - The slug of the blog post
* @param locale - The locale of the blog post
* @returns The blog post * @returns The blog post
* *
* How it works: * How it works:
@ -31,16 +32,8 @@ import '@/styles/mdx.css';
* slug becomes "first-post" after join('/') * slug becomes "first-post" after join('/')
* Matches post where slugAsParams === "first-post" AND locale === params.locale * Matches post where slugAsParams === "first-post" AND locale === params.locale
*/ */
async function getBlogPostFromParams(props: NextPageProps) { async function getBlogPostFromParams(locale: Locale, slug: string) {
const params = await props.params; // console.log('getBlogPostFromParams', locale, slug);
if (!params) {
return null;
}
const locale = params.locale as string;
const slug =
(Array.isArray(params.slug) ? params.slug?.join('/') : params.slug) || '';
// Find post with matching slug and locale // Find post with matching slug and locale
const post = allPosts.find( const post = allPosts.find(
(post) => (post) =>
@ -76,22 +69,23 @@ async function getRelatedPosts(post: Post) {
return relatedPosts; return relatedPosts;
} }
export function generateStaticParams() {
return LOCALES.flatMap((locale) => {
const posts = allPosts.filter((post) => post.locale === locale);
return posts.map((post) => ({
locale,
slug: [post.slugAsParams],
}));
});
}
export async function generateMetadata({ export async function generateMetadata({
params, params,
}: { }: BlogPostPageProps): Promise<Metadata | undefined> {
params: Promise<{ slug: string; locale: Locale }>; const { locale, slug } = await params;
}): Promise<Metadata | undefined> { const post = await getBlogPostFromParams(locale, slug.join('/'));
const { slug, locale } = await params;
const post = await getBlogPostFromParams({
params: Promise.resolve({ slug, locale }),
searchParams: Promise.resolve({}),
});
if (!post) { if (!post) {
console.warn( notFound();
`generateMetadata, post not found for slug: ${slug}, locale: ${locale}`
);
return {};
} }
const t = await getTranslations({ locale, namespace: 'Metadata' }); const t = await getTranslations({ locale, namespace: 'Metadata' });
@ -100,11 +94,20 @@ export async function generateMetadata({
title: `${post.title} | ${t('title')}`, title: `${post.title} | ${t('title')}`,
description: post.description, description: post.description,
canonicalUrl: getUrlWithLocale(post.slug, locale), canonicalUrl: getUrlWithLocale(post.slug, locale),
image: post.image,
}); });
} }
export default async function BlogPostPage(props: NextPageProps) { interface BlogPostPageProps {
const post = await getBlogPostFromParams(props); params: Promise<{
locale: Locale;
slug: string[];
}>;
}
export default async function BlogPostPage(props: BlogPostPageProps) {
const { locale, slug } = await props.params;
const post = await getBlogPostFromParams(locale, slug.join('/'));
if (!post) { if (!post) {
notFound(); notFound();
} }
@ -112,6 +115,8 @@ export default async function BlogPostPage(props: NextPageProps) {
const publishDate = post.date; const publishDate = post.date;
const date = formatDate(new Date(publishDate)); const date = formatDate(new Date(publishDate));
const toc = await getTableOfContents(post.content); const toc = await getTableOfContents(post.content);
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
const t = await getTranslations('BlogPage'); const t = await getTranslations('BlogPage');
// get related posts // get related posts

View File

@ -16,16 +16,14 @@ import {
DocsPage, DocsPage,
DocsTitle, DocsTitle,
} from 'fumadocs-ui/page'; } from 'fumadocs-ui/page';
import type { Metadata } from 'next';
import type { Locale } from 'next-intl'; import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server'; import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
export function generateStaticParams() { export function generateStaticParams() {
const locales = LOCALES;
const slugParams = source.generateParams(); const slugParams = source.generateParams();
const params = locales.flatMap((locale) => const params = LOCALES.flatMap((locale) =>
slugParams.map((param) => ({ slugParams.map((param) => ({
locale, locale,
slug: param.slug, slug: param.slug,

View File

@ -1,9 +1,13 @@
import { Analytics } from '@/analytics/analytics';
import { import {
fontBricolageGrotesque, fontBricolageGrotesque,
fontNotoSans, fontNotoSans,
fontNotoSansMono, fontNotoSansMono,
fontNotoSerif, fontNotoSerif,
} from '@/assets/fonts'; } from '@/assets/fonts';
import AffonsoScript from '@/components/affiliate/affonso';
import PromotekitScript from '@/components/affiliate/promotekit';
import { TailwindIndicator } from '@/components/layout/tailwind-indicator';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl'; import { type Locale, NextIntlClientProvider, hasLocale } from 'next-intl';
@ -13,8 +17,6 @@ import { Toaster } from 'sonner';
import { Providers } from './providers'; import { Providers } from './providers';
import '@/styles/globals.css'; import '@/styles/globals.css';
import { Analytics } from '@/analytics/analytics';
import { TailwindIndicator } from '@/components/layout/tailwind-indicator';
interface LocaleLayoutProps { interface LocaleLayoutProps {
children: ReactNode; children: ReactNode;
@ -41,6 +43,10 @@ export default async function LocaleLayout({
return ( return (
<html suppressHydrationWarning lang={locale}> <html suppressHydrationWarning lang={locale}>
<head>
<AffonsoScript />
<PromotekitScript />
</head>
<body <body
suppressHydrationWarning suppressHydrationWarning
className={cn( className={cn(

View File

@ -1,3 +1,4 @@
import { websiteConfig } from '@/config/website';
import { getLocalePathname } from '@/i18n/navigation'; import { getLocalePathname } from '@/i18n/navigation';
import { routing } from '@/i18n/routing'; import { routing } from '@/i18n/routing';
import { source } from '@/lib/docs/source'; import { source } from '@/lib/docs/source';
@ -60,15 +61,73 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
) )
); );
// add posts // add paginated blog list pages
sitemapList.push( routing.locales.forEach((locale) => {
...allPosts.flatMap((post: { slugAsParams: string }) => const posts = allPosts.filter(
routing.locales.map((locale) => ({ (post) => post.locale === locale && post.published
url: getUrl(`/blog/${post.slugAsParams}`, locale), );
const totalPages = Math.max(
1,
Math.ceil(posts.length / websiteConfig.blog.paginationSize)
);
// /blog/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/page/${page}`, locale),
lastModified: new Date(), lastModified: new Date(),
priority: 0.8, priority: 0.8,
changeFrequency: 'weekly' as const, changeFrequency: 'weekly' as const,
})) });
}
});
// add paginated category pages
routing.locales.forEach((locale) => {
const localeCategories = allCategories.filter(
(category) => category.locale === locale
);
localeCategories.forEach((category) => {
// posts in this category and locale
const postsInCategory = allPosts.filter(
(post) =>
post.locale === locale &&
post.published &&
post.categories.some((cat) => cat && cat.slug === category.slug)
);
const totalPages = Math.max(
1,
Math.ceil(postsInCategory.length / websiteConfig.blog.paginationSize)
);
// /blog/category/[slug] (first page)
sitemapList.push({
url: getUrl(`/blog/category/${category.slug}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
// /blog/category/[slug]/page/[page] (from 2)
for (let page = 2; page <= totalPages; page++) {
sitemapList.push({
url: getUrl(`/blog/category/${category.slug}/page/${page}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
});
}
});
});
// add posts (single post pages)
sitemapList.push(
...allPosts.flatMap((post: { slugAsParams: string; locale: string }) =>
routing.locales
.filter((locale) => post.locale === locale)
.map((locale) => ({
url: getUrl(`/blog/${post.slugAsParams}`, locale),
lastModified: new Date(),
priority: 0.8,
changeFrequency: 'weekly' as const,
}))
) )
); );

View File

@ -0,0 +1,33 @@
'use client';
import { websiteConfig } from '@/config/website';
import Script from 'next/script';
/**
* Affonso Affiliate
*
* https://affonso.com
*/
export default function AffonsoScript() {
if (process.env.NODE_ENV !== 'production') {
return null;
}
if (!websiteConfig.features.enableAffonsoAffiliate) {
return null;
}
const affiliateId = process.env.NEXT_PUBLIC_AFFILIATE_AFFONSO_ID as string;
if (!affiliateId) {
return null;
}
return (
<Script
src="https://affonso.io/js/pixel.min.js"
strategy="afterInteractive"
data-affonso={affiliateId}
data-cookie_duration="30"
/>
);
}

View File

@ -0,0 +1,34 @@
'use client';
import { websiteConfig } from '@/config/website';
import Script from 'next/script';
/**
* PromoteKit
*
* https://www.promotekit.com
*/
export default function PromotekitScript() {
if (process.env.NODE_ENV !== 'production') {
return null;
}
if (!websiteConfig.features.enablePromotekitAffiliate) {
return null;
}
const promotekitKey = process.env.NEXT_PUBLIC_AFFILIATE_PROMOTEKIT_ID;
if (!promotekitKey) {
return null;
}
return (
<>
<Script
src="https://cdn.promotekit.com/promotekit.js"
data-promotekit={promotekitKey}
strategy="afterInteractive"
/>
</>
);
}

View File

@ -13,6 +13,7 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client'; import { authClient } from '@/lib/auth-client';
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls'; import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes'; import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
@ -97,6 +98,13 @@ export const RegisterForm = ({
// sign up success, user information stored in ctx.data // sign up success, user information stored in ctx.data
// console.log("register, success:", ctx.data); // console.log("register, success:", ctx.data);
setSuccess(t('checkEmail')); setSuccess(t('checkEmail'));
// add affonso affiliate
// https://affonso.io/app/affiliate-program/connect
if (websiteConfig.features.enableAffonsoAffiliate) {
console.log('register, affonso affiliate:', values.email);
window.Affonso.signup(values.email);
}
}, },
onError: (ctx) => { onError: (ctx) => {
// sign up fail, display the error message // sign up fail, display the error message

View File

@ -0,0 +1,33 @@
import type { Post } from 'content-collections';
import EmptyGrid from '../shared/empty-grid';
import CustomPagination from '../shared/pagination';
import BlogGrid from './blog-grid';
interface BlogGridWithPaginationProps {
posts: Post[];
totalPages: number;
routePrefix: string;
}
export default function BlogGridWithPagination({
posts,
totalPages,
routePrefix,
}: BlogGridWithPaginationProps) {
return (
<div>
{posts.length === 0 && <EmptyGrid />}
{posts.length > 0 && (
<div>
<BlogGrid posts={posts} />
<div className="mt-8 flex items-center justify-center">
<CustomPagination
routePrefix={routePrefix}
totalPages={totalPages}
/>
</div>
</div>
)}
</div>
);
}

View File

@ -10,6 +10,7 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar,
} from '@/components/ui/sidebar'; } from '@/components/ui/sidebar';
import { getSidebarLinks } from '@/config/sidebar-config'; import { getSidebarLinks } from '@/config/sidebar-config';
import { LocaleLink } from '@/i18n/navigation'; import { LocaleLink } from '@/i18n/navigation';
@ -31,6 +32,7 @@ export function DashboardSidebar({
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { data: session, isPending } = authClient.useSession(); const { data: session, isPending } = authClient.useSession();
const currentUser = session?.user; const currentUser = session?.user;
const { state } = useSidebar();
// console.log('sidebar currentUser:', currentUser); // console.log('sidebar currentUser:', currentUser);
const sidebarLinks = getSidebarLinks(); const sidebarLinks = getSidebarLinks();
@ -73,8 +75,8 @@ export function DashboardSidebar({
{/* Only show UI components when not in loading state */} {/* Only show UI components when not in loading state */}
{!isPending && mounted && ( {!isPending && mounted && (
<> <>
{/* show upgrade card if user is not a member */} {/* show upgrade card if user is not a member, and sidebar is not collapsed */}
{currentUser && <UpgradeCard />} {currentUser && state !== 'collapsed' && <UpgradeCard />}
{/* show user profile if user is logged in */} {/* show user profile if user is logged in */}
{currentUser && <SidebarUser user={currentUser} />} {currentUser && <SidebarUser user={currentUser} />}

View File

@ -8,6 +8,7 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from '@/components/ui/card'; } from '@/components/ui/card';
import { websiteConfig } from '@/config/website';
import { usePayment } from '@/hooks/use-payment'; import { usePayment } from '@/hooks/use-payment';
import { LocaleLink } from '@/i18n/navigation'; import { LocaleLink } from '@/i18n/navigation';
import { Routes } from '@/routes'; import { Routes } from '@/routes';
@ -16,6 +17,10 @@ import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
export function UpgradeCard() { export function UpgradeCard() {
if (!websiteConfig.features.enableUpgradeCard) {
return null;
}
const t = useTranslations('Dashboard.upgrade'); const t = useTranslations('Dashboard.upgrade');
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
const { isLoading, currentPlan, subscription } = usePayment(); const { isLoading, currentPlan, subscription } = usePayment();

View File

@ -103,7 +103,7 @@ export function NavbarMobile({
aria-expanded={open} aria-expanded={open}
aria-label="Toggle Mobile Menu" aria-label="Toggle Mobile Menu"
onClick={handleToggleMobileMenu} onClick={handleToggleMobileMenu}
className="size-8 flex aspect-square h-fit select-none items-center className="size-8 flex aspect-square h-fit select-none items-center
justify-center rounded-md border cursor-pointer" justify-center rounded-md border cursor-pointer"
> >
{open ? ( {open ? (
@ -118,7 +118,7 @@ export function NavbarMobile({
{/* mobile menu */} {/* mobile menu */}
{open && ( {open && (
<Portal asChild> <Portal asChild>
{/* if we don't add RemoveScroll component, the underlying {/* if we don't add RemoveScroll component, the underlying
page will scroll when we scroll the mobile menu */} page will scroll when we scroll the mobile menu */}
<RemoveScroll allowPinchZoom enabled> <RemoveScroll allowPinchZoom enabled>
{/* Only render MainMobileMenu when not in loading state */} {/* Only render MainMobileMenu when not in loading state */}
@ -188,10 +188,15 @@ function MainMobileMenu({ userLoggedIn, onLinkClicked }: MainMobileMenuProps) {
<ul className="w-full px-4"> <ul className="w-full px-4">
{menuLinks?.map((item) => { {menuLinks?.map((item) => {
const isActive = item.href const isActive = item.href
? localePathname.startsWith(item.href) ? item.href === '/'
? localePathname === '/'
: localePathname.startsWith(item.href)
: item.items?.some( : item.items?.some(
(subItem) => (subItem) =>
subItem.href && localePathname.startsWith(subItem.href) subItem.href &&
(subItem.href === '/'
? localePathname === '/'
: localePathname.startsWith(subItem.href))
); );
return ( return (

View File

@ -60,7 +60,7 @@ export function Navbar({ scroll }: NavBarProps) {
return ( return (
<section <section
className={cn( className={cn(
'sticky inset-x-0 top-0 z-40 py-4 transition-all duration-300', 'sticky inset-x-0 top-0 z-100 py-4 transition-all duration-300',
scroll scroll
? scrolled ? scrolled
? 'bg-background/80 backdrop-blur-md border-b supports-backdrop-filter:bg-background/60' ? 'bg-background/80 backdrop-blur-md border-b supports-backdrop-filter:bg-background/60'
@ -193,7 +193,9 @@ export function Navbar({ scroll }: NavBarProps) {
asChild asChild
active={ active={
item.href item.href
? localePathname.startsWith(item.href) ? item.href === '/'
? localePathname === '/'
: localePathname.startsWith(item.href)
: false : false
} }
className={customNavigationMenuTriggerStyle} className={customNavigationMenuTriggerStyle}

View File

@ -2,6 +2,7 @@
import { createCheckoutAction } from '@/actions/create-checkout-session'; import { createCheckoutAction } from '@/actions/create-checkout-session';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
import { Loader2Icon } from 'lucide-react'; import { Loader2Icon } from 'lucide-react';
import { useTranslations } from 'next-intl'; import { useTranslations } from 'next-intl';
import { useState } from 'react'; import { useState } from 'react';
@ -50,12 +51,50 @@ export function CheckoutButton({
try { try {
setIsLoading(true); setIsLoading(true);
const mergedMetadata = metadata ? { ...metadata } : {};
// add promotekit_referral to metadata if enabled promotekit affiliate
if (websiteConfig.features.enablePromotekitAffiliate) {
const promotekitReferral =
typeof window !== 'undefined'
? (window as any).promotekit_referral
: undefined;
if (promotekitReferral) {
console.log(
'create checkout button, promotekitReferral:',
promotekitReferral
);
mergedMetadata.promotekit_referral = promotekitReferral;
}
}
// add affonso_referral to metadata if enabled affonso affiliate
if (websiteConfig.features.enableAffonsoAffiliate) {
const affonsoReferral =
typeof document !== 'undefined'
? (() => {
const match = document.cookie.match(
/(?:^|; )affonso_referral=([^;]*)/
);
return match ? decodeURIComponent(match[1]) : null;
})()
: null;
if (affonsoReferral) {
console.log(
'create checkout button, affonsoReferral:',
affonsoReferral
);
mergedMetadata.affonso_referral = affonsoReferral;
}
}
// Create checkout session using server action // Create checkout session using server action
const result = await createCheckoutAction({ const result = await createCheckoutAction({
userId, userId,
planId, planId,
priceId, priceId,
metadata, metadata:
Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
}); });
// Redirect to checkout page // Redirect to checkout page

View File

@ -1,6 +1,7 @@
'use client'; 'use client';
import { DiscordIcon } from '@/components/icons/discord'; import { DiscordIcon } from '@/components/icons/discord';
import { websiteConfig } from '@/config/website';
import { useMediaQuery } from '@/hooks/use-media-query'; import { useMediaQuery } from '@/hooks/use-media-query';
import WidgetBot from '@widgetbot/react-embed'; import WidgetBot from '@widgetbot/react-embed';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
@ -11,6 +12,10 @@ import { useEffect, useRef, useState } from 'react';
* https://docs.widgetbot.io/embed/react-embed/ * https://docs.widgetbot.io/embed/react-embed/
*/ */
export default function DiscordWidget() { export default function DiscordWidget() {
if (!websiteConfig.features.enableDiscordWidget) {
return null;
}
const serverId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID as string; const serverId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_SERVER_ID as string;
const channelId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID as string; const channelId = process.env.NEXT_PUBLIC_DISCORD_WIDGET_CHANNEL_ID as string;
if (!serverId || !channelId) { if (!serverId || !channelId) {

View File

@ -9,26 +9,38 @@ import {
PaginationNext, PaginationNext,
PaginationPrevious, PaginationPrevious,
} from '@/components/ui/pagination'; } from '@/components/ui/pagination';
import { useLocaleRouter } from '@/i18n/navigation'; import { useLocalePathname, useLocaleRouter } from '@/i18n/navigation';
import { useSearchParams } from 'next/navigation';
function getCurrentPageFromPath(pathname: string): number {
const match = pathname.match(/\/page\/(\d+)$/);
if (match?.[1]) {
return Number(match[1]);
}
return 1;
}
type CustomPaginationProps = { type CustomPaginationProps = {
totalPages: number; totalPages: number;
routePreix: string; routePrefix: string;
}; };
export default function CustomPagination({ export default function CustomPagination({
totalPages, totalPages,
routePreix, routePrefix,
}: CustomPaginationProps) { }: CustomPaginationProps) {
const router = useLocaleRouter(); const router = useLocaleRouter();
const searchParams = useSearchParams(); const pathname = useLocalePathname();
const currentPage = Number(searchParams.get('page')) || 1; const currentPage = getCurrentPageFromPath(pathname);
const handlePageChange = (page: number | string) => { const handlePageChange = (page: number | string) => {
const params = new URLSearchParams(searchParams); const pageNum = Number(page);
params.set('page', page.toString()); if (pageNum === 1) {
router.push(`${routePreix}?${params.toString()}`); // Go to /blog or /blog/category/[slug] for page 1
router.push(routePrefix);
} else {
// Go to /blog/page/x or /blog/category/[slug]/page/x
router.push(`${routePrefix}/page/${pageNum}`);
}
}; };
const allPages = generatePagination(currentPage, totalPages); const allPages = generatePagination(currentPage, totalPages);

View File

@ -32,6 +32,12 @@ export const websiteConfig: WebsiteConfig = {
youtube: 'https://mksaas.link/youtube', youtube: 'https://mksaas.link/youtube',
}, },
}, },
features: {
enableDiscordWidget: false,
enableUpgradeCard: true,
enableAffonsoAffiliate: false,
enablePromotekitAffiliate: false,
},
routes: { routes: {
defaultLoginRedirect: '/dashboard', defaultLoginRedirect: '/dashboard',
}, },

42
src/lib/blog/data.ts Normal file
View File

@ -0,0 +1,42 @@
import { websiteConfig } from '@/config/website';
import { allPosts } from 'content-collections';
export function getPaginatedBlogPosts({
locale,
page = 1,
category,
}: {
locale: string;
page?: number;
category?: string;
}) {
// Filter posts by locale
let filteredPosts = allPosts.filter(
(post) => post.locale === locale && post.published
);
// If no posts found for the current locale, show all published posts
if (filteredPosts.length === 0) {
filteredPosts = allPosts.filter((post) => post.published);
}
// Filter by category if category is provided
if (category) {
filteredPosts = filteredPosts.filter((post) =>
post.categories.some((cat) => cat && cat.slug === category)
);
}
// Sort posts by date (newest first)
const sortedPosts = [...filteredPosts].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
);
// Paginate posts
const paginationSize = websiteConfig.blog.paginationSize;
const startIndex = (page - 1) * paginationSize;
const endIndex = startIndex + paginationSize;
const paginatedPosts = sortedPosts.slice(startIndex, endIndex);
const totalPages = Math.ceil(filteredPosts.length / paginationSize);
return {
paginatedPosts,
totalPages,
filteredPostsCount: filteredPosts.length,
};
}

View File

@ -37,7 +37,7 @@ export function constructMetadata({
url: canonicalUrl, url: canonicalUrl,
title, title,
description, description,
siteName: title, siteName: defaultMessages.Metadata.name,
images: [ogImageUrl.toString()], images: [ogImageUrl.toString()],
}, },
twitter: { twitter: {

11
src/types/index.d.ts vendored
View File

@ -5,6 +5,7 @@ import type { ReactNode } from 'react';
*/ */
export type WebsiteConfig = { export type WebsiteConfig = {
metadata: MetadataConfig; metadata: MetadataConfig;
features: FeaturesConfig;
routes: RoutesConfig; routes: RoutesConfig;
analytics: AnalyticsConfig; analytics: AnalyticsConfig;
auth: AuthConfig; auth: AuthConfig;
@ -60,6 +61,16 @@ export interface SocialConfig {
telegram?: string; telegram?: string;
} }
/**
* Website features
*/
export interface FeaturesConfig {
enableDiscordWidget?: boolean; // Whether to enable the discord widget
enableUpgradeCard?: boolean; // Whether to enable the upgrade card in the sidebar
enableAffonsoAffiliate?: boolean; // Whether to enable affonso affiliate
enablePromotekitAffiliate?: boolean; // Whether to enable promotekit affiliate
}
/** /**
* Routes configuration * Routes configuration
*/ */