From 9aeb59dff2447b29d904561708fc97d4424b670d Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 16:50:13 +0800 Subject: [PATCH 1/7] chore: update .dockerignore, .gitignore, and biome.json to include .conductor directory --- .dockerignore | 1 + .gitignore | 3 +++ biome.json | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.dockerignore b/.dockerignore index 913f304..704908b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,6 @@ .cursor .claude +.conductor .kiro .github .next diff --git a/.gitignore b/.gitignore index c5900c6..f81e144 100644 --- a/.gitignore +++ b/.gitignore @@ -41,6 +41,9 @@ certificates # claude code .claude +# conductor +.conductor + # kiro .kiro diff --git a/biome.json b/biome.json index bcb08bb..75868e3 100644 --- a/biome.json +++ b/biome.json @@ -14,6 +14,7 @@ ".cursor/**", ".claude/**", ".kiro/**", + ".conductor/**", ".vscode/**", ".source/**", "node_modules/**", @@ -77,6 +78,7 @@ ".wrangler/**", ".cursor/**", ".claude/**", + ".conductor/**", ".kiro/**", ".vscode/**", ".source/**", From 66d7dd3259a81cee72b3926e33be133eed8ae2be Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 01:12:56 +0800 Subject: [PATCH 2/7] feat: add premium content feature with related components and configuration --- content/blog/premium-fumadocs.mdx | 67 +++++++ messages/en.json | 15 +- source.config.ts | 1 + src/components/blog/blog-card.tsx | 8 + src/components/blog/premium-badge.tsx | 45 +++++ src/components/blog/premium-content.tsx | 18 ++ src/components/blog/premium-guard.tsx | 230 ++++++++++++++++++++++++ src/components/docs/mdx-components.tsx | 2 + 8 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 content/blog/premium-fumadocs.mdx create mode 100644 src/components/blog/premium-badge.tsx create mode 100644 src/components/blog/premium-content.tsx create mode 100644 src/components/blog/premium-guard.tsx diff --git a/content/blog/premium-fumadocs.mdx b/content/blog/premium-fumadocs.mdx new file mode 100644 index 0000000..45c8d3a --- /dev/null +++ b/content/blog/premium-fumadocs.mdx @@ -0,0 +1,67 @@ +--- +title: "Premium: What is Fumadocs" +description: "Introducing Fumadocs, a docs framework that you can break." +date: "2025-08-30" +published: true +premium: true +categories: ["development", "nextjs"] +author: "fox" +image: "/images/blog/post-1.png" +--- + +Fumadocs was created because I wanted a more customisable experience for building docs, to be a docs framework that is not opinionated, **a "framework" that you can break**. + +## Philosophy + +**Less Abstraction:** Fumadocs expects you to write code and cooperate with the rest of your software. +While most frameworks are configured with a configuration file, they usually lack flexibility when you hope to tune its details. +You can't control how they render the page nor the internal logic. Fumadocs shows you how the app works, instead of a single configuration file. + +**Next.js Fundamentals:** It gives you the utilities and a good-looking UI. +You are still using features of Next.js App Router, like **Static Site Generation**. There is nothing new for Next.js developers, so you can use it with confidence. + + + +**Opinionated on UI:** The only thing Fumadocs UI (the default theme) offers is **User Interface**. The UI is opinionated for bringing better mobile responsiveness and user experience. +Instead, we use a much more flexible approach inspired by Shadcn UI — [Fumadocs CLI](/docs/cli), so we can iterate our design quick, and welcome for more feedback about the UI. + +## Why Fumadocs + +Fumadocs is designed with flexibility in mind. + +You can use `fumadocs-core` as a headless UI library and bring your own styles. +Fumadocs MDX is also a useful library to handle MDX content in Next.js. It also includes: + +- Many built-in components. +- Typescript Twoslash, OpenAPI, and Math (KaTeX) integrations. +- Fast and optimized by default, natively built on App Router. +- Tight integration with Next.js, you can add it to an existing Next.js project easily. + +You can read [Comparisons](/docs/comparisons) if you're interested. + +### Documentation + +Fumadocs focuses on **authoring experience**, it provides a beautiful theme and many docs automation tools. + +It helps you to iterate your codebase faster while never leaving your docs behind. +You can take this site as an example of docs site built with Fumadocs. + +### Blog sites + +Since Next.js is already a powerful framework, most features can be implemented with **just Next.js**. + +Fumadocs provides additional tooling for Next.js, including syntax highlighting, document search, and a default theme (Fumadocs UI). +It helps you to avoid reinventing the wheels. + +## When to use Fumadocs + +For most of the web applications, vanilla React.js is no longer enough. +Nowadays, we also wish to have a blog, a showcase page, a FAQ page, etc. With a +fancy UI that's breathtaking, in these cases, Fumadocs can help you build the +docs easier, with less boilerplate. + +Fumadocs is maintained by Fuma and many contributors, with care on the maintainability of codebase. +While we don't aim to offer every functionality people wanted, we're more focused on making basic features perfect and well-maintained. +You can also help Fumadocs to be more useful by contributing! + + diff --git a/messages/en.json b/messages/en.json index cbd0f4d..9399fc2 100644 --- a/messages/en.json +++ b/messages/en.json @@ -281,7 +281,20 @@ "all": "All", "noPostsFound": "No posts found", "allPosts": "All Posts", - "morePosts": "More Posts" + "morePosts": "More Posts", + "premiumContent": { + "title": "Unlock Premium Content", + "description": "Subscribe to our Pro plan to access all premium articles and exclusive content.", + "upgradeCta": "Upgrade Now", + "benefit1": "All premium articles", + "benefit2": "Exclusive content", + "benefit3": "Cancel anytime", + "signIn": "Sign In", + "loginRequired": "Sign in to continue reading", + "loginDescription": "This is a premium article. Sign in to your account to access the full content.", + "checkingAccess": "Checking access...", + "loadingContent": "Loading full content..." + } }, "DocsPage": { "toc": "Table of Contents", diff --git a/source.config.ts b/source.config.ts index 7917344..b2feabf 100644 --- a/source.config.ts +++ b/source.config.ts @@ -94,6 +94,7 @@ export const blog = defineCollections({ image: z.string(), date: z.string().date(), published: z.boolean().default(true), + premium: z.boolean().optional(), categories: z.array(z.string()), author: z.string(), }), diff --git a/src/components/blog/blog-card.tsx b/src/components/blog/blog-card.tsx index 7d587c9..23eff59 100644 --- a/src/components/blog/blog-card.tsx +++ b/src/components/blog/blog-card.tsx @@ -4,6 +4,7 @@ import { formatDate } from '@/lib/formatter'; import { type BlogType, authorSource, categorySource } from '@/lib/source'; import Image from 'next/image'; import BlogImage from './blog-image'; +import { PremiumBadge } from './premium-badge'; interface BlogCardProps { locale: string; @@ -30,6 +31,13 @@ export default function BlogCard({ locale, post }: BlogCardProps) { title={title || 'image for blog post'} /> + {/* Premium badge - top right */} + {post.data.premium && ( +
+ +
+ )} + {/* categories */} {blogCategories && blogCategories.length > 0 && (
diff --git a/src/components/blog/premium-badge.tsx b/src/components/blog/premium-badge.tsx new file mode 100644 index 0000000..51772df --- /dev/null +++ b/src/components/blog/premium-badge.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Badge } from '@/components/ui/badge'; +import { cn } from '@/lib/utils'; +import { CrownIcon } from 'lucide-react'; + +interface PremiumBadgeProps { + className?: string; + variant?: 'default' | 'outline' | 'secondary'; + size?: 'sm' | 'default' | 'lg'; +} + +export function PremiumBadge({ + className, + variant = 'default', + size = 'default', +}: PremiumBadgeProps) { + const sizeClasses = { + sm: 'text-xs h-5', + default: 'text-xs h-6', + lg: 'text-sm h-7', + }; + + const iconSizes = { + sm: 'size-3', + default: 'size-3', + lg: 'size-4', + }; + + return ( + + + Premium + + ); +} diff --git a/src/components/blog/premium-content.tsx b/src/components/blog/premium-content.tsx new file mode 100644 index 0000000..e3b9ca8 --- /dev/null +++ b/src/components/blog/premium-content.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useCurrentUser } from '@/hooks/use-current-user'; +import { useCurrentPlan } from '@/hooks/use-payment'; +import type { ReactNode } from 'react'; + +interface PremiumContentProps { + children: ReactNode; +} + +/** + * This component will now rely on server-side filtering + * The tags will be removed server-side for non-premium users + * This component only serves as a marker for premium sections + */ +export function PremiumContent({ children }: PremiumContentProps) { + return
{children}
; +} diff --git a/src/components/blog/premium-guard.tsx b/src/components/blog/premium-guard.tsx new file mode 100644 index 0000000..f9f6109 --- /dev/null +++ b/src/components/blog/premium-guard.tsx @@ -0,0 +1,230 @@ +'use client'; + +import { LoginWrapper } from '@/components/auth/login-wrapper'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { useCurrentUser } from '@/hooks/use-current-user'; +import { useCurrentPlan } from '@/hooks/use-payment'; +import { LocaleLink, useLocalePathname } from '@/i18n/navigation'; +import { + ArrowRightIcon, + CheckCircleIcon, + CrownIcon, + Loader2Icon, + LockIcon, +} from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import type { ReactNode } from 'react'; + +interface PremiumGuardProps { + children: ReactNode; + isPremium: boolean; + canAccess?: boolean; + className?: string; +} + +export function PremiumGuard({ + children, + isPremium, + canAccess, + className, +}: PremiumGuardProps) { + // For non-premium articles, show content immediately with no extra processing + if (!isPremium) { + return ( +
+
+ {children} +
+
+ ); + } + + const t = useTranslations('BlogPage'); + const pathname = useLocalePathname(); + + // If server-side check has already determined access, use that + if (canAccess !== undefined) { + // Server has determined the user has access + if (canAccess) { + return ( +
+
+ {children} +
+
+ ); + } + + // Server determined no access, show appropriate message + const currentUser = useCurrentUser(); + if (!currentUser) { + return ( +
+
+ {/* Show partial content before protection */} + {children} +
+ + {/* Enhanced login prompt for server-side blocked content */} +
+
+
+
+ +
+ +
+

+ {t('premiumContent.loginRequired')} +

+

+ {t('premiumContent.loginDescription')} +

+
+ + + + +
+
+
+
+ ); + } + } + + // Fallback to client-side check when server-side check is not available + const currentUser = useCurrentUser(); + const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan( + currentUser?.id + ); + + // Determine if user has premium access + const hasPremiumAccess = + paymentData?.currentPlan && + (!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime); + + // If user is not logged in + if (!currentUser) { + return ( +
+
+ {children} +
+ + {/* Enhanced login prompt */} +
+
+
+
+ +
+ +
+

+ {t('premiumContent.loginRequired')} +

+

+ {t('premiumContent.loginDescription')} +

+
+ + + + +
+
+
+
+ ); + } + + // If payment data is still loading + if (isLoadingPayment) { + return ( +
+
+ {children} +
+ {isLoadingPayment && ( +
+ + {t('premiumContent.checkingAccess')} +
+ )} +
+ ); + } + + // If user doesn't have premium access + if (!hasPremiumAccess) { + return ( +
+
+ {children} +
+ + {/* Inline subscription banner for logged-in non-members */} +
+ + +
+
+ +
+
+ +

+ {t('premiumContent.title')} +

+ +

+ {t('premiumContent.description')} +

+ +
+ +
+ +
+ + + {t('premiumContent.benefit1')} + + + + {t('premiumContent.benefit2')} + + + + {t('premiumContent.benefit3')} + +
+
+
+
+
+ ); + } + + // Show full content for premium users + return ( +
+
+ {children} +
+
+ ); +} diff --git a/src/components/docs/mdx-components.tsx b/src/components/docs/mdx-components.tsx index c7c2400..adcfe52 100644 --- a/src/components/docs/mdx-components.tsx +++ b/src/components/docs/mdx-components.tsx @@ -1,3 +1,4 @@ +import { PremiumContent } from '@/components/blog/premium-content'; import { ImageWrapper } from '@/components/docs/image-wrapper'; import { Wrapper } from '@/components/docs/wrapper'; import { YoutubeVideo } from '@/components/docs/youtube-video'; @@ -23,6 +24,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents { ...LucideIcons, // ...((await import('lucide-react')) as unknown as MDXComponents), YoutubeVideo, + PremiumContent, Tabs, Tab, TypeTable, From 481f3268db6bb952f0bfc19617ebaa7022150521 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 09:55:24 +0800 Subject: [PATCH 3/7] feat: implement premium access checks and enhance premium content handling in blog posts --- .../(marketing)/blog/[...slug]/page.tsx | 24 ++++- src/components/blog/premium-content.tsx | 20 ++++- src/components/blog/premium-guard.tsx | 32 ++++--- src/lib/premium-access.ts | 89 +++++++++++++++++++ 4 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 src/lib/premium-access.ts diff --git a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx index bec8bde..0a544e2 100644 --- a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx @@ -1,11 +1,13 @@ import AllPostsButton from '@/components/blog/all-posts-button'; import BlogGrid from '@/components/blog/blog-grid'; +import { PremiumGuard } from '@/components/blog/premium-guard'; import { getMDXComponents } from '@/components/docs/mdx-components'; -import { NewsletterCard } from '@/components/newsletter/newsletter-card'; import { websiteConfig } from '@/config/website'; import { LocaleLink } from '@/i18n/navigation'; import { formatDate } from '@/lib/formatter'; import { constructMetadata } from '@/lib/metadata'; +import { checkPremiumAccess } from '@/lib/premium-access'; +import { getSession } from '@/lib/server'; import { type BlogType, authorSource, @@ -83,7 +85,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) { notFound(); } - const { date, title, description, image, author, categories } = post.data; + const { date, title, description, image, author, categories, premium } = + post.data; const publishDate = formatDate(new Date(date)); const blogAuthor = authorSource.getPage([author], locale); @@ -91,6 +94,13 @@ export default async function BlogPostPage(props: BlogPostPageProps) { .getPages(locale) .filter((category) => categories.includes(category.slugs[0] ?? '')); + // Check premium access for premium posts + const session = await getSession(); + const hasPremiumAccess = + premium && session?.user?.id + ? await checkPremiumAccess(session.user.id) + : !premium; // Non-premium posts are always accessible + const MDX = post.data.body; // getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static @@ -141,8 +151,14 @@ export default async function BlogPostPage(props: BlogPostPageProps) { {/* blog post content */} {/* in order to make the mdx.css work, we need to add the className prose to the div */} {/* https://github.com/tailwindlabs/tailwindcss-typography */} -
- +
+ + +
diff --git a/src/components/blog/premium-content.tsx b/src/components/blog/premium-content.tsx index e3b9ca8..e56fb1e 100644 --- a/src/components/blog/premium-content.tsx +++ b/src/components/blog/premium-content.tsx @@ -9,10 +9,24 @@ interface PremiumContentProps { } /** - * This component will now rely on server-side filtering - * The tags will be removed server-side for non-premium users - * This component only serves as a marker for premium sections + * Client-side Premium Content component + * Note: This component now serves as a fallback for client-side rendering. + * The main security filtering happens server-side in PremiumGuard component. */ export function PremiumContent({ children }: PremiumContentProps) { + const currentUser = useCurrentUser(); + const { data: paymentData } = useCurrentPlan(currentUser?.id); + + // Determine if user has premium access + const hasPremiumAccess = + paymentData?.currentPlan && + (paymentData.currentPlan.isLifetime || !paymentData.currentPlan.isFree); + + // Only show content if user has premium access + // This is a client-side fallback - main security is handled server-side + if (!currentUser || !hasPremiumAccess) { + return null; + } + return
{children}
; } diff --git a/src/components/blog/premium-guard.tsx b/src/components/blog/premium-guard.tsx index f9f6109..9462dcc 100644 --- a/src/components/blog/premium-guard.tsx +++ b/src/components/blog/premium-guard.tsx @@ -29,6 +29,14 @@ export function PremiumGuard({ canAccess, className, }: PremiumGuardProps) { + // All hooks must be called unconditionally at the top + const t = useTranslations('BlogPage'); + const pathname = useLocalePathname(); + const currentUser = useCurrentUser(); + const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan( + currentUser?.id + ); + // For non-premium articles, show content immediately with no extra processing if (!isPremium) { return ( @@ -40,9 +48,11 @@ export function PremiumGuard({ ); } - const t = useTranslations('BlogPage'); - const pathname = useLocalePathname(); - + // Determine if user has premium access + const hasPremiumAccess = + paymentData?.currentPlan && + (!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime); + // If server-side check has already determined access, use that if (canAccess !== undefined) { // Server has determined the user has access @@ -55,9 +65,8 @@ export function PremiumGuard({
); } - + // Server determined no access, show appropriate message - const currentUser = useCurrentUser(); if (!currentUser) { return (
@@ -65,7 +74,7 @@ export function PremiumGuard({ {/* Show partial content before protection */} {children}
- + {/* Enhanced login prompt for server-side blocked content */}
@@ -97,17 +106,6 @@ export function PremiumGuard({ } } - // Fallback to client-side check when server-side check is not available - const currentUser = useCurrentUser(); - const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan( - currentUser?.id - ); - - // Determine if user has premium access - const hasPremiumAccess = - paymentData?.currentPlan && - (!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime); - // If user is not logged in if (!currentUser) { return ( diff --git a/src/lib/premium-access.ts b/src/lib/premium-access.ts new file mode 100644 index 0000000..6eec9a9 --- /dev/null +++ b/src/lib/premium-access.ts @@ -0,0 +1,89 @@ +import { getDb } from '@/db'; +import { payment } from '@/db/schema'; +import { findPlanByPriceId, getAllPricePlans } from '@/lib/price-plan'; +import { PaymentTypes } from '@/payment/types'; +import { and, eq, gt, isNull, or } from 'drizzle-orm'; + +/** + * Check premium access for a specific user ID + * + * This function combines the logic from getLifetimeStatusAction and getActiveSubscriptionAction + * but optimizes it for a single database query to check premium access. + */ +export async function checkPremiumAccess(userId: string): Promise { + try { + const db = await getDb(); + + // Get lifetime plan IDs for efficient checking + const plans = getAllPricePlans(); + const lifetimePlanIds = plans + .filter((plan) => plan.isLifetime) + .map((plan) => plan.id); + + // Single optimized query to check both lifetime and active subscriptions + const result = await db + .select({ + id: payment.id, + priceId: payment.priceId, + type: payment.type, + status: payment.status, + periodEnd: payment.periodEnd, + cancelAtPeriodEnd: payment.cancelAtPeriodEnd, + }) + .from(payment) + .where( + and( + eq(payment.userId, userId), + or( + // Check for completed lifetime payments + and( + eq(payment.type, PaymentTypes.ONE_TIME), + eq(payment.status, 'completed') + ), + // Check for active subscriptions that haven't expired + and( + eq(payment.type, PaymentTypes.SUBSCRIPTION), + eq(payment.status, 'active'), + or( + // Either period hasn't ended yet + gt(payment.periodEnd, new Date()), + // Or period end is null (ongoing subscription) + isNull(payment.periodEnd) + ) + ) + ) + ) + ); + + if (!result || result.length === 0) { + return false; + } + + // Check if any payment grants premium access + return result.some((p) => { + // For one-time payments, check if it's a lifetime plan + if (p.type === PaymentTypes.ONE_TIME && p.status === 'completed') { + const plan = findPlanByPriceId(p.priceId); + return plan && lifetimePlanIds.includes(plan.id); + } + + // For subscriptions, check if they're active and not expired + if (p.type === PaymentTypes.SUBSCRIPTION && p.status === 'active') { + // If periodEnd is null, it's an ongoing subscription + if (!p.periodEnd) { + return true; + } + + // Check if the subscription period hasn't ended yet + const now = new Date(); + const periodEnd = new Date(p.periodEnd); + return periodEnd > now; + } + + return false; + }); + } catch (error) { + console.error('Error checking premium access for user:', error); + return false; + } +} From 4faa89c0ee3d051400c77aedab97a067a774e460 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 14:55:19 +0800 Subject: [PATCH 4/7] custom: replace premium Fumadocs blog post and integrate premium badge display --- content/blog/premium-fumadocs.mdx | 67 ------------------- content/blog/premium.mdx | 56 ++++++++++++++++ .../(marketing)/blog/[...slug]/page.tsx | 5 +- 3 files changed, 60 insertions(+), 68 deletions(-) delete mode 100644 content/blog/premium-fumadocs.mdx create mode 100644 content/blog/premium.mdx diff --git a/content/blog/premium-fumadocs.mdx b/content/blog/premium-fumadocs.mdx deleted file mode 100644 index 45c8d3a..0000000 --- a/content/blog/premium-fumadocs.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "Premium: What is Fumadocs" -description: "Introducing Fumadocs, a docs framework that you can break." -date: "2025-08-30" -published: true -premium: true -categories: ["development", "nextjs"] -author: "fox" -image: "/images/blog/post-1.png" ---- - -Fumadocs was created because I wanted a more customisable experience for building docs, to be a docs framework that is not opinionated, **a "framework" that you can break**. - -## Philosophy - -**Less Abstraction:** Fumadocs expects you to write code and cooperate with the rest of your software. -While most frameworks are configured with a configuration file, they usually lack flexibility when you hope to tune its details. -You can't control how they render the page nor the internal logic. Fumadocs shows you how the app works, instead of a single configuration file. - -**Next.js Fundamentals:** It gives you the utilities and a good-looking UI. -You are still using features of Next.js App Router, like **Static Site Generation**. There is nothing new for Next.js developers, so you can use it with confidence. - - - -**Opinionated on UI:** The only thing Fumadocs UI (the default theme) offers is **User Interface**. The UI is opinionated for bringing better mobile responsiveness and user experience. -Instead, we use a much more flexible approach inspired by Shadcn UI — [Fumadocs CLI](/docs/cli), so we can iterate our design quick, and welcome for more feedback about the UI. - -## Why Fumadocs - -Fumadocs is designed with flexibility in mind. - -You can use `fumadocs-core` as a headless UI library and bring your own styles. -Fumadocs MDX is also a useful library to handle MDX content in Next.js. It also includes: - -- Many built-in components. -- Typescript Twoslash, OpenAPI, and Math (KaTeX) integrations. -- Fast and optimized by default, natively built on App Router. -- Tight integration with Next.js, you can add it to an existing Next.js project easily. - -You can read [Comparisons](/docs/comparisons) if you're interested. - -### Documentation - -Fumadocs focuses on **authoring experience**, it provides a beautiful theme and many docs automation tools. - -It helps you to iterate your codebase faster while never leaving your docs behind. -You can take this site as an example of docs site built with Fumadocs. - -### Blog sites - -Since Next.js is already a powerful framework, most features can be implemented with **just Next.js**. - -Fumadocs provides additional tooling for Next.js, including syntax highlighting, document search, and a default theme (Fumadocs UI). -It helps you to avoid reinventing the wheels. - -## When to use Fumadocs - -For most of the web applications, vanilla React.js is no longer enough. -Nowadays, we also wish to have a blog, a showcase page, a FAQ page, etc. With a -fancy UI that's breathtaking, in these cases, Fumadocs can help you build the -docs easier, with less boilerplate. - -Fumadocs is maintained by Fuma and many contributors, with care on the maintainability of codebase. -While we don't aim to offer every functionality people wanted, we're more focused on making basic features perfect and well-maintained. -You can also help Fumadocs to be more useful by contributing! - - diff --git a/content/blog/premium.mdx b/content/blog/premium.mdx new file mode 100644 index 0000000..53f887f --- /dev/null +++ b/content/blog/premium.mdx @@ -0,0 +1,56 @@ +--- +title: "Premium Blog Post" +description: "This blog post is a test for premium content." +date: "2025-08-30" +published: true +premium: true +categories: ["development"] +author: "fox" +image: "/images/blog/post-7.png" +--- + +This blog post is a test for premium content. + +You can read this part of the blog post if you are not a premium user. + +But for the rest of the blog post, you need to be logged in as a premium user. + +You can click the "Sign In" button to sign in as a user with free plan. + +Then you can click the "Upgrade Now" button to upgrade to a premium plan. + + + Don't worry, you don't actually pay any cents, because we are in the sandbox environment of Stripe. + + +You can use the test card number to pay for monthly or yearly PRO plan or LIFETIME plan. + +``` +4242 4242 4242 4242 +Exp: 12/34 +CVV: 567 +``` + +After that, you can return to the blog post and you can read the rest of the blog post. + +For more details, please check out the documentation: [Blog](https://mksaas.com/docs/blog). + +Now the rest of the blog post is premium content. + + + + + This is the beginning of the premium content part. + + +This is the premium content part. + +You can read this paragraph only if you are a premium user. + +Please don't share this blog post with others. + + + This is the end of the premium content part. + + + diff --git a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx index 0a544e2..62fbbf0 100644 --- a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx @@ -23,6 +23,7 @@ import Image from 'next/image'; import { notFound } from 'next/navigation'; import '@/styles/mdx.css'; +import { PremiumBadge } from '@/components/blog/premium-badge'; import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; /** @@ -131,7 +132,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) { )}
- {/* blog post date */} + {/* blog post date and premium badge */}
@@ -139,6 +140,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) { {publishDate}
+ + {premium && }
{/* blog post title */} From e5061b3b672a6955a0aa7c22e31ed6617bf4f087 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 16:48:11 +0800 Subject: [PATCH 5/7] custom: correct typo in source.config.ts and add premium content translations --- content/blog/premium.zh.mdx | 56 +++++++++++++++++++++++++++ messages/en.json | 1 + messages/zh.json | 16 +++++++- source.config.ts | 2 +- src/components/blog/premium-badge.tsx | 5 ++- 5 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 content/blog/premium.zh.mdx diff --git a/content/blog/premium.zh.mdx b/content/blog/premium.zh.mdx new file mode 100644 index 0000000..bcd2e71 --- /dev/null +++ b/content/blog/premium.zh.mdx @@ -0,0 +1,56 @@ +--- +title: "测试专用付费文章" +description: "这是一篇测试专用付费文章。" +date: "2025-08-30" +published: true +premium: true +categories: ["development"] +author: "fox" +image: "/images/blog/post-7.png" +--- + +这是一篇测试专用的付费文章。 + +如果你不是付费用户,你可以阅读这篇文章的这部分内容。 + +但如果你想阅读剩下的内容,你需要成为一个付费用户。 + +你可以点击 "登录" 按钮来以免费用户的身份登录。 + +然后你可以点击 "立即升级" 按钮来升级到付费计划。 + + + 不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。 + + +你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。 + +``` +4242 4242 4242 4242 +Exp: 12/34 +CVV: 567 +``` + +之后,你可以返回这篇博客文章,然后你可以阅读剩下的内容。 + +更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。 + +现在剩下的内容是付费内容。 + + + + + 这是付费内容部分的开始。 + + +这是付费内容部分。 + +你可以阅读这篇内容,只要你是一个付费用户。 + +请不要分享这篇文章给其他人。 + + + 这是付费内容部分的结束。 + + + diff --git a/messages/en.json b/messages/en.json index 9399fc2..8b32a7e 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5,6 +5,7 @@ "description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly" }, "Common": { + "premium": "Premium", "login": "Log in", "logout": "Log out", "signUp": "Sign up", diff --git a/messages/zh.json b/messages/zh.json index ffb9f34..9a23698 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -5,6 +5,7 @@ "description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。" }, "Common": { + "premium": "付费文章", "login": "登录", "logout": "退出", "signUp": "注册", @@ -281,7 +282,20 @@ "all": "全部", "noPostsFound": "没有找到文章", "allPosts": "全部文章", - "morePosts": "更多文章" + "morePosts": "更多文章", + "premiumContent": { + "title": "解锁付费内容", + "description": "订阅我们的付费计划,访问所有付费文章和独家内容。", + "upgradeCta": "立即升级", + "benefit1": "所有文章", + "benefit2": "独家内容", + "benefit3": "随时取消", + "signIn": "登录", + "loginRequired": "登录以继续阅读", + "loginDescription": "这是一篇付费文章,请登录您的账户以访问完整内容。", + "checkingAccess": "检查阅读权限...", + "loadingContent": "加载完整内容..." + } }, "DocsPage": { "toc": "目录", diff --git a/source.config.ts b/source.config.ts index b2feabf..1243d2d 100644 --- a/source.config.ts +++ b/source.config.ts @@ -85,7 +85,7 @@ export const category = defineCollections({ /** * Blog posts * - * dtitle is required, but description is optional in frontmatter + * title is required, but description is optional in frontmatter */ export const blog = defineCollections({ type: 'doc', diff --git a/src/components/blog/premium-badge.tsx b/src/components/blog/premium-badge.tsx index 51772df..cff5f75 100644 --- a/src/components/blog/premium-badge.tsx +++ b/src/components/blog/premium-badge.tsx @@ -3,6 +3,7 @@ import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { CrownIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; interface PremiumBadgeProps { className?: string; @@ -15,6 +16,8 @@ export function PremiumBadge({ variant = 'default', size = 'default', }: PremiumBadgeProps) { + const t = useTranslations('Common'); + const sizeClasses = { sm: 'text-xs h-5', default: 'text-xs h-6', @@ -39,7 +42,7 @@ export function PremiumBadge({ )} > - Premium + {t('premium')} ); } From e2dfab2ca7e2578bdd5427f70069ec055766dc62 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 21:44:14 +0800 Subject: [PATCH 6/7] fix: update categories from "development" to "product" in premium blog post files --- content/blog/premium.mdx | 2 +- content/blog/premium.zh.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/blog/premium.mdx b/content/blog/premium.mdx index 53f887f..eec0bdb 100644 --- a/content/blog/premium.mdx +++ b/content/blog/premium.mdx @@ -4,7 +4,7 @@ description: "This blog post is a test for premium content." date: "2025-08-30" published: true premium: true -categories: ["development"] +categories: ["product"] author: "fox" image: "/images/blog/post-7.png" --- diff --git a/content/blog/premium.zh.mdx b/content/blog/premium.zh.mdx index bcd2e71..eb9df1f 100644 --- a/content/blog/premium.zh.mdx +++ b/content/blog/premium.zh.mdx @@ -4,7 +4,7 @@ description: "这是一篇测试专用付费文章。" date: "2025-08-30" published: true premium: true -categories: ["development"] +categories: ["product"] author: "fox" image: "/images/blog/post-7.png" --- From a92ef86a719c7278ab702fa2b9485b8a61d3868b Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 21:58:05 +0800 Subject: [PATCH 7/7] fix: clarify test card number format and clean up imports in page component --- content/blog/premium.mdx | 2 +- content/blog/premium.zh.mdx | 2 +- src/app/[locale]/(marketing)/blog/[...slug]/page.tsx | 5 +++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/content/blog/premium.mdx b/content/blog/premium.mdx index eec0bdb..4361376 100644 --- a/content/blog/premium.mdx +++ b/content/blog/premium.mdx @@ -26,7 +26,7 @@ Then you can click the "Upgrade Now" button to upgrade to a premium plan. You can use the test card number to pay for monthly or yearly PRO plan or LIFETIME plan. ``` -4242 4242 4242 4242 +Card number: 4242 4242 4242 4242 Exp: 12/34 CVV: 567 ``` diff --git a/content/blog/premium.zh.mdx b/content/blog/premium.zh.mdx index eb9df1f..a80ac60 100644 --- a/content/blog/premium.zh.mdx +++ b/content/blog/premium.zh.mdx @@ -26,7 +26,7 @@ image: "/images/blog/post-7.png" 你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。 ``` -4242 4242 4242 4242 +Card number: 4242 4242 4242 4242 Exp: 12/34 CVV: 567 ``` diff --git a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx index 62fbbf0..f97e99e 100644 --- a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx @@ -1,7 +1,9 @@ import AllPostsButton from '@/components/blog/all-posts-button'; import BlogGrid from '@/components/blog/blog-grid'; +import { PremiumBadge } from '@/components/blog/premium-badge'; import { PremiumGuard } from '@/components/blog/premium-guard'; import { getMDXComponents } from '@/components/docs/mdx-components'; +import { NewsletterCard } from '@/components/newsletter/newsletter-card'; import { websiteConfig } from '@/config/website'; import { LocaleLink } from '@/i18n/navigation'; import { formatDate } from '@/lib/formatter'; @@ -15,6 +17,7 @@ import { categorySource, } from '@/lib/source'; import { getUrlWithLocale } from '@/lib/urls/urls'; +import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; import { CalendarIcon, FileTextIcon } from 'lucide-react'; import type { Metadata } from 'next'; import type { Locale } from 'next-intl'; @@ -23,8 +26,6 @@ import Image from 'next/image'; import { notFound } from 'next/navigation'; import '@/styles/mdx.css'; -import { PremiumBadge } from '@/components/blog/premium-badge'; -import { InlineTOC } from 'fumadocs-ui/components/inline-toc'; /** * get related posts, random pick from all posts with same locale, different slug,