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

This commit is contained in:
javayhu 2025-06-08 11:13:00 +08:00
commit 8b3e9ecfe1
25 changed files with 592 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)
- 🌐 demo: [demo.mksaas.com](https://demo.mksaas.com)
- 📚 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)
- 📹 video (WIP): [mksaas.link/youtube](https://mksaas.link/youtube)
@ -27,10 +27,10 @@ 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.
- [MkSaaSHQ/mksaas-template](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com (ready)
- [MkSaaSHQ/mksaas-blog](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me (ready)
- [MkSaaSHQ/mksaas-app](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app (WIP)
- [MkSaaSHQ/mksaas-haitang](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app (WIP)
- [MkSaaSHQ/mksaas-template (ready)](https://github.com/MkSaaSHQ/mksaas-template): https://demo.mksaas.com
- [MkSaaSHQ/mksaas-blog (ready)](https://github.com/MkSaaSHQ/mksaas-blog): https://mksaas.me
- [MkSaaSHQ/mksaas-app (WIP)](https://github.com/MkSaaSHQ/mksaas-app): https://mksaas.app
- [MkSaaSHQ/mksaas-haitang (WIP)](https://github.com/MkSaaSHQ/mksaas-haitang): https://haitang.app
## Notice

View File

@ -129,3 +129,17 @@ NEXT_PUBLIC_DATAFAST_ANALYTICS_DOMAIN=""
# -----------------------------------------------------------------------------
NEXT_PUBLIC_DISCORD_WIDGET_SERVER_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

@ -12,7 +12,6 @@ import StatsSection from '@/components/blocks/stats/stats';
import TestimonialsSection from '@/components/blocks/testimonials/testimonials';
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import DiscordWidget from '@/components/shared/discord-widget';
import { websiteConfig } from '@/config/website';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { Metadata } from 'next';
@ -75,7 +74,7 @@ export default async function HomePage(props: HomePageProps) {
<NewsletterCard />
{websiteConfig.features.enableDiscordWidget && <DiscordWidget />}
<DiscordWidget />
</div>
</>
);

View File

@ -1,103 +1,73 @@
import BlogGrid from '@/components/blog/blog-grid';
import EmptyGrid from '@/components/shared/empty-grid';
import CustomPagination from '@/components/shared/pagination';
import { websiteConfig } from '@/config/website';
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import { allCategories, allPosts } from 'content-collections';
import type { Metadata } from 'next';
import { allCategories } from 'content-collections';
import type { Locale } from 'next-intl';
import { getTranslations } from 'next-intl/server';
import { notFound } from 'next/navigation';
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string; locale: Locale }>;
}): Promise<Metadata | undefined> {
const { slug, locale } = await params;
// 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}`
// Generate all static params for SSG (locale + category)
export function generateStaticParams() {
const params: { locale: string; slug: string }[] = [];
for (const locale of LOCALES) {
const localeCategories = allCategories.filter(
(category) => category.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 canonicalPath = `/blog/category/${slug}`;
return constructMetadata({
title: `${category.name} | ${t('title')}`,
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({
params,
searchParams,
}: NextPageProps) {
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
}: BlogCategoryPageProps) {
const { locale, slug } = await params;
const category = allCategories.find(
(category) => category.slug === slug && category.locale === locale
(category) => category.locale === locale && category.slug === slug
);
// Filter posts by category and locale
const filteredPosts = allPosts.filter((post) => {
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
);
if (!category) {
notFound();
}
const currentPage = 1;
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
category: 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 (
<div>
{/* when no posts are found */}
{paginatedPosts.length === 0 && <EmptyGrid />}
{/* 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>
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={`/blog/category/${slug}`}
/>
);
}

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 EmptyGrid from '@/components/shared/empty-grid';
import CustomPagination from '@/components/shared/pagination';
import { websiteConfig } from '@/config/website';
import BlogGridWithPagination from '@/components/blog/blog-grid-with-pagination';
import { LOCALES } from '@/i18n/routing';
import { getPaginatedBlogPosts } from '@/lib/blog/data';
import { constructMetadata } from '@/lib/metadata';
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 { getTranslations } from 'next-intl/server';
export async function generateMetadata({
params,
}: {
params: Promise<{ locale: Locale }>;
}): Promise<Metadata | undefined> {
export function generateStaticParams() {
return LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }: BlogPageProps) {
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'Metadata' });
const pt = await getTranslations({ locale, namespace: 'BlogPage' });
const canonicalPath = '/blog';
return constructMetadata({
title: pt('title') + ' | ' + t('title'),
title: `${pt('title')} | ${t('title')}`,
description: pt('description'),
canonicalUrl: getUrlWithLocale('/blog', locale),
canonicalUrl: getUrlWithLocale(canonicalPath, locale),
});
}
export default async function BlogPage({
params,
searchParams,
}: 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,);
interface BlogPageProps {
params: Promise<{
locale: Locale;
}>;
}
export default async function BlogPage({ params }: BlogPageProps) {
const { locale } = await params;
const currentPage = 1;
const { paginatedPosts, totalPages } = getPaginatedBlogPosts({
locale,
page: currentPage,
});
return (
<div>
{/* when no posts are found */}
{paginatedPosts.length === 0 && <EmptyGrid />}
{/* 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>
<BlogGridWithPagination
posts={paginatedPosts}
totalPages={totalPages}
routePrefix={'/blog'}
/>
);
}

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 { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation';
import { LOCALES } from '@/i18n/routing';
import { getTableOfContents } from '@/lib/blog/toc';
import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata';
import { getUrlWithLocale } from '@/lib/urls/urls';
import type { NextPageProps } from '@/types/next-page-props';
import { type Post, allPosts } from 'content-collections';
import { CalendarIcon, ClockIcon, FileTextIcon } from 'lucide-react';
import type { Metadata } from 'next';
@ -22,7 +22,8 @@ import '@/styles/mdx.css';
/**
* 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
*
* How it works:
@ -31,16 +32,8 @@ import '@/styles/mdx.css';
* slug becomes "first-post" after join('/')
* Matches post where slugAsParams === "first-post" AND locale === params.locale
*/
async function getBlogPostFromParams(props: NextPageProps) {
const params = await props.params;
if (!params) {
return null;
}
const locale = params.locale as string;
const slug =
(Array.isArray(params.slug) ? params.slug?.join('/') : params.slug) || '';
async function getBlogPostFromParams(locale: Locale, slug: string) {
// console.log('getBlogPostFromParams', locale, slug);
// Find post with matching slug and locale
const post = allPosts.find(
(post) =>
@ -76,22 +69,23 @@ async function getRelatedPosts(post: Post) {
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({
params,
}: {
params: Promise<{ slug: string; locale: Locale }>;
}): Promise<Metadata | undefined> {
const { slug, locale } = await params;
const post = await getBlogPostFromParams({
params: Promise.resolve({ slug, locale }),
searchParams: Promise.resolve({}),
});
}: BlogPostPageProps): Promise<Metadata | undefined> {
const { locale, slug } = await params;
const post = await getBlogPostFromParams(locale, slug.join('/'));
if (!post) {
console.warn(
`generateMetadata, post not found for slug: ${slug}, locale: ${locale}`
);
return {};
notFound();
}
const t = await getTranslations({ locale, namespace: 'Metadata' });
@ -104,8 +98,16 @@ export async function generateMetadata({
});
}
export default async function BlogPostPage(props: NextPageProps) {
const post = await getBlogPostFromParams(props);
interface BlogPostPageProps {
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) {
notFound();
}
@ -113,6 +115,8 @@ export default async function BlogPostPage(props: NextPageProps) {
const publishDate = post.date;
const date = formatDate(new Date(publishDate));
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');
// get related posts

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import { websiteConfig } from '@/config/website';
import { getLocalePathname } from '@/i18n/navigation';
import { routing } from '@/i18n/routing';
import { source } from '@/lib/docs/source';
@ -60,15 +61,73 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
)
);
// add posts
sitemapList.push(
...allPosts.flatMap((post: { slugAsParams: string }) =>
routing.locales.map((locale) => ({
url: getUrl(`/blog/${post.slugAsParams}`, locale),
// add paginated blog list pages
routing.locales.forEach((locale) => {
const posts = allPosts.filter(
(post) => post.locale === locale && post.published
);
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(),
priority: 0.8,
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,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import { websiteConfig } from '@/config/website';
import { authClient } from '@/lib/auth-client';
import { getUrlWithLocaleInCallbackUrl } from '@/lib/urls/urls';
import { DEFAULT_LOGIN_REDIRECT, Routes } from '@/routes';
@ -97,6 +98,13 @@ export const RegisterForm = ({
// sign up success, user information stored in ctx.data
// console.log("register, success:", ctx.data);
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) => {
// 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

@ -13,7 +13,6 @@ import {
useSidebar,
} from '@/components/ui/sidebar';
import { getSidebarLinks } from '@/config/sidebar-config';
import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation';
import { authClient } from '@/lib/auth-client';
import { Routes } from '@/routes';
@ -77,9 +76,7 @@ export function DashboardSidebar({
{!isPending && mounted && (
<>
{/* show upgrade card if user is not a member, and sidebar is not collapsed */}
{currentUser &&
state !== 'collapsed' &&
websiteConfig.features.enableUpgradeCard && <UpgradeCard />}
{currentUser && state !== 'collapsed' && <UpgradeCard />}
{/* show user profile if user is logged in */}
{currentUser && <SidebarUser user={currentUser} />}

View File

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

View File

@ -103,7 +103,7 @@ export function NavbarMobile({
aria-expanded={open}
aria-label="Toggle Mobile Menu"
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"
>
{open ? (
@ -118,7 +118,7 @@ export function NavbarMobile({
{/* mobile menu */}
{open && (
<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 */}
<RemoveScroll allowPinchZoom enabled>
{/* Only render MainMobileMenu when not in loading state */}
@ -188,10 +188,15 @@ function MainMobileMenu({ userLoggedIn, onLinkClicked }: MainMobileMenuProps) {
<ul className="w-full px-4">
{menuLinks?.map((item) => {
const isActive = item.href
? localePathname.startsWith(item.href)
? item.href === '/'
? localePathname === '/'
: localePathname.startsWith(item.href)
: item.items?.some(
(subItem) =>
subItem.href && localePathname.startsWith(subItem.href)
subItem.href &&
(subItem.href === '/'
? localePathname === '/'
: localePathname.startsWith(subItem.href))
);
return (

View File

@ -60,7 +60,7 @@ export function Navbar({ scroll }: NavBarProps) {
return (
<section
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
? scrolled
? 'bg-background/80 backdrop-blur-md border-b supports-backdrop-filter:bg-background/60'
@ -193,7 +193,9 @@ export function Navbar({ scroll }: NavBarProps) {
asChild
active={
item.href
? localePathname.startsWith(item.href)
? item.href === '/'
? localePathname === '/'
: localePathname.startsWith(item.href)
: false
}
className={customNavigationMenuTriggerStyle}

View File

@ -2,6 +2,7 @@
import { createCheckoutAction } from '@/actions/create-checkout-session';
import { Button } from '@/components/ui/button';
import { websiteConfig } from '@/config/website';
import { Loader2Icon } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
@ -50,12 +51,50 @@ export function CheckoutButton({
try {
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
const result = await createCheckoutAction({
userId,
planId,
priceId,
metadata,
metadata:
Object.keys(mergedMetadata).length > 0 ? mergedMetadata : undefined,
});
// Redirect to checkout page

View File

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

View File

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

View File

@ -33,8 +33,10 @@ export const websiteConfig: WebsiteConfig = {
},
},
features: {
enableDiscordWidget: true,
enableDiscordWidget: false,
enableUpgradeCard: true,
enableAffonsoAffiliate: false,
enablePromotekitAffiliate: false,
},
routes: {
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

@ -67,6 +67,8 @@ export interface SocialConfig {
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
}
/**