Merge remote-tracking branch 'origin/main' into cloudflare
This commit is contained in:
commit
b6836db12d
12
README.md
12
README.md
@ -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 you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll assist you in resolving the issue.
|
By default, you should have access to all four repositories. If you find that you’re unable to access any of them, please don’t hesitate to reach out to me, and I’ll 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.
|
||||||
|
14
env.example
14
env.example
@ -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=""
|
||||||
|
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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}`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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'}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
@ -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
|
||||||
|
@ -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,
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
}))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
33
src/components/affiliate/affonso.tsx
Normal file
33
src/components/affiliate/affonso.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
34
src/components/affiliate/promotekit.tsx
Normal file
34
src/components/affiliate/promotekit.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -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
|
||||||
|
33
src/components/blog/blog-grid-with-pagination.tsx
Normal file
33
src/components/blog/blog-grid-with-pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -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} />}
|
||||||
|
@ -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();
|
||||||
|
@ -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 (
|
||||||
|
@ -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}
|
||||||
|
@ -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
|
||||||
|
@ -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) {
|
||||||
|
@ -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);
|
||||||
|
@ -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
42
src/lib/blog/data.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
@ -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
11
src/types/index.d.ts
vendored
@ -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
|
||||||
*/
|
*/
|
||||||
|
Loading…
Reference in New Issue
Block a user