Merge pull request #86 from MkSaaSHQ/dev/blog-premium

feat: premium content in blog posts
This commit is contained in:
javayhu 2025-08-31 22:03:43 +08:00 committed by GitHub
commit 1f9a7c2621
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 576 additions and 8 deletions

56
content/blog/premium.mdx Normal file
View File

@ -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: ["product"]
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.
<Callout type="warn">
Don't worry, you don't actually pay any cents, because we are in the sandbox environment of Stripe.
</Callout>
You can use the test card number to pay for monthly or yearly PRO plan or LIFETIME plan.
```
Card number: 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.
<PremiumContent>
<Callout type="info">
This is the beginning of the premium content part.
</Callout>
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.
<Callout type="info">
This is the end of the premium content part.
</Callout>
</PremiumContent>

View File

@ -0,0 +1,56 @@
---
title: "测试专用付费文章"
description: "这是一篇测试专用付费文章。"
date: "2025-08-30"
published: true
premium: true
categories: ["product"]
author: "fox"
image: "/images/blog/post-7.png"
---
这是一篇测试专用的付费文章。
如果你不是付费用户,你可以阅读这篇文章的这部分内容。
但如果你想阅读剩下的内容,你需要成为一个付费用户。
你可以点击 "登录" 按钮来以免费用户的身份登录。
然后你可以点击 "立即升级" 按钮来升级到付费计划。
<Callout type="warn">
不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。
</Callout>
你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。
```
Card number: 4242 4242 4242 4242
Exp: 12/34
CVV: 567
```
之后,你可以返回这篇博客文章,然后你可以阅读剩下的内容。
更多详情,请参考文档:[博客](https://mksaas.com/docs/blog)。
现在剩下的内容是付费内容。
<PremiumContent>
<Callout type="info">
这是付费内容部分的开始。
</Callout>
这是付费内容部分。
你可以阅读这篇内容,只要你是一个付费用户。
请不要分享这篇文章给其他人。
<Callout type="info">
这是付费内容部分的结束。
</Callout>
</PremiumContent>

View File

@ -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",
@ -281,7 +282,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",

View File

@ -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": "目录",

View File

@ -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',
@ -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(),
}),

View File

@ -1,11 +1,15 @@
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';
import { constructMetadata } from '@/lib/metadata';
import { checkPremiumAccess } from '@/lib/premium-access';
import { getSession } from '@/lib/server';
import {
type BlogType,
authorSource,
@ -13,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';
@ -21,7 +26,6 @@ import Image from 'next/image';
import { notFound } from 'next/navigation';
import '@/styles/mdx.css';
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
/**
* get related posts, random pick from all posts with same locale, different slug,
@ -83,7 +87,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 +96,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
@ -121,7 +133,7 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
)}
</div>
{/* blog post date */}
{/* blog post date and premium badge */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<CalendarIcon className="size-4 text-muted-foreground" />
@ -129,6 +141,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{publishDate}
</span>
</div>
{premium && <PremiumBadge size="sm" />}
</div>
{/* blog post title */}
@ -141,8 +155,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 */}
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
<MDX components={getMDXComponents()} />
<div className="mt-8">
<PremiumGuard
isPremium={!!premium}
canAccess={hasPremiumAccess}
className="max-w-none"
>
<MDX components={getMDXComponents()} />
</PremiumGuard>
</div>
<div className="flex items-center justify-start my-16">

View File

@ -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 && (
<div className="absolute top-2 right-2 z-20">
<PremiumBadge size="sm" />
</div>
)}
{/* categories */}
{blogCategories && blogCategories.length > 0 && (
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300 z-20">

View File

@ -0,0 +1,48 @@
'use client';
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;
variant?: 'default' | 'outline' | 'secondary';
size?: 'sm' | 'default' | 'lg';
}
export function PremiumBadge({
className,
variant = 'default',
size = 'default',
}: PremiumBadgeProps) {
const t = useTranslations('Common');
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 (
<Badge
variant={variant}
className={cn(
'inline-flex items-center gap-1 font-medium',
'bg-gradient-to-r from-amber-500 to-orange-500',
'text-white border-0 hover:from-amber-600 hover:to-orange-600',
sizeClasses[size],
className
)}
>
<CrownIcon className={iconSizes[size]} />
{t('premium')}
</Badge>
);
}

View File

@ -0,0 +1,32 @@
'use client';
import { useCurrentUser } from '@/hooks/use-current-user';
import { useCurrentPlan } from '@/hooks/use-payment';
import type { ReactNode } from 'react';
interface PremiumContentProps {
children: ReactNode;
}
/**
* 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 <div className="premium-content-section">{children}</div>;
}

View File

@ -0,0 +1,228 @@
'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) {
// 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 (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
</div>
);
}
// 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
if (canAccess) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
</div>
);
}
// Server determined no access, show appropriate message
if (!currentUser) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{/* Show partial content before protection */}
{children}
</div>
{/* Enhanced login prompt for server-side blocked content */}
<div className="mt-16">
<div className="w-full p-12 rounded-lg bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
<div className="flex flex-col items-center justify-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<LockIcon className="size-8 text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">
{t('premiumContent.loginRequired')}
</h3>
<p className="text-muted-foreground max-w-md">
{t('premiumContent.loginDescription')}
</p>
</div>
<LoginWrapper mode="modal" asChild callbackUrl={pathname}>
<Button size="lg" className="min-w-[160px] cursor-pointer">
<LockIcon className="mr-2 size-4" />
{t('premiumContent.signIn')}
</Button>
</LoginWrapper>
</div>
</div>
</div>
</div>
);
}
}
// If user is not logged in
if (!currentUser) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
{/* Enhanced login prompt */}
<div className="mt-16">
<div className="w-full p-12 rounded-lg bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
<div className="flex flex-col items-center justify-center gap-6 text-center">
<div className="p-4 rounded-full bg-primary/10">
<LockIcon className="size-8 text-primary" />
</div>
<div className="space-y-2">
<h3 className="text-xl font-semibold">
{t('premiumContent.loginRequired')}
</h3>
<p className="text-muted-foreground max-w-md">
{t('premiumContent.loginDescription')}
</p>
</div>
<LoginWrapper mode="modal" asChild callbackUrl={pathname}>
<Button size="lg" className="min-w-[160px] cursor-pointer">
<LockIcon className="mr-2 size-4" />
{t('premiumContent.signIn')}
</Button>
</LoginWrapper>
</div>
</div>
</div>
</div>
);
}
// If payment data is still loading
if (isLoadingPayment) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
{isLoadingPayment && (
<div className="mt-8 flex items-center justify-center text-primary font-semibold">
<Loader2Icon className="size-5 animate-spin mr-2" />
<span>{t('premiumContent.checkingAccess')}</span>
</div>
)}
</div>
);
}
// If user doesn't have premium access
if (!hasPremiumAccess) {
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
{/* Inline subscription banner for logged-in non-members */}
<div className="mt-16">
<Card className="bg-gradient-to-br from-primary/5 via-primary/10 to-secondary/5 border border-primary/20">
<CardContent className="p-12 text-center">
<div className="flex justify-center mb-6">
<div className="p-4 rounded-full bg-primary/10">
<CrownIcon className="size-8 text-primary" />
</div>
</div>
<h3 className="text-xl font-semibold mb-2">
{t('premiumContent.title')}
</h3>
<p className="text-muted-foreground mb-6 max-w-md mx-auto">
{t('premiumContent.description')}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center items-center">
<Button asChild size="lg" className="min-w-[160px]">
<LocaleLink href="/pricing">
{t('premiumContent.upgradeCta')}
<ArrowRightIcon className="ml-2 size-4" />
</LocaleLink>
</Button>
</div>
<div className="mt-8 flex items-center justify-center gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-2">
<CheckCircleIcon className="size-4 text-primary" />
{t('premiumContent.benefit1')}
</span>
<span className="flex items-center gap-2">
<CheckCircleIcon className="size-4 text-primary" />
{t('premiumContent.benefit2')}
</span>
<span className="flex items-center gap-2">
<CheckCircleIcon className="size-4 text-primary" />
{t('premiumContent.benefit3')}
</span>
</div>
</CardContent>
</Card>
</div>
</div>
);
}
// Show full content for premium users
return (
<div className={className}>
<div className="prose prose-neutral dark:prose-invert prose-img:rounded-lg max-w-none">
{children}
</div>
</div>
);
}

View File

@ -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,

89
src/lib/premium-access.ts Normal file
View File

@ -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<boolean> {
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;
}
}