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/**", diff --git a/content/blog/premium.mdx b/content/blog/premium.mdx new file mode 100644 index 0000000..4361376 --- /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: ["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. + + + 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. + +``` +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. + + + + + 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/content/blog/premium.zh.mdx b/content/blog/premium.zh.mdx new file mode 100644 index 0000000..a80ac60 --- /dev/null +++ b/content/blog/premium.zh.mdx @@ -0,0 +1,56 @@ +--- +title: "测试专用付费文章" +description: "这是一篇测试专用付费文章。" +date: "2025-08-30" +published: true +premium: true +categories: ["product"] +author: "fox" +image: "/images/blog/post-7.png" +--- + +这是一篇测试专用的付费文章。 + +如果你不是付费用户,你可以阅读这篇文章的这部分内容。 + +但如果你想阅读剩下的内容,你需要成为一个付费用户。 + +你可以点击 "登录" 按钮来以免费用户的身份登录。 + +然后你可以点击 "立即升级" 按钮来升级到付费计划。 + + + 不用担心,你实际上不需要支付任何费用,因为我们处于 Stripe 的沙盒环境中。 + + +你可以使用测试卡号来支付月度或年度 PRO 计划或终身计划。 + +``` +Card number: 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 cbd0f4d..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", @@ -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", 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 7917344..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', @@ -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/app/[locale]/(marketing)/blog/[...slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx index bec8bde..f97e99e 100644 --- a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx @@ -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) { )} - {/* blog post date */} + {/* blog post date and premium badge */}
@@ -129,6 +141,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) { {publishDate}
+ + {premium && }
{/* 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 */} -
- +
+ + +
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..cff5f75 --- /dev/null +++ b/src/components/blog/premium-badge.tsx @@ -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 ( + + + {t('premium')} + + ); +} diff --git a/src/components/blog/premium-content.tsx b/src/components/blog/premium-content.tsx new file mode 100644 index 0000000..e56fb1e --- /dev/null +++ b/src/components/blog/premium-content.tsx @@ -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
{children}
; +} diff --git a/src/components/blog/premium-guard.tsx b/src/components/blog/premium-guard.tsx new file mode 100644 index 0000000..9462dcc --- /dev/null +++ b/src/components/blog/premium-guard.tsx @@ -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 ( +
+
+ {children} +
+
+ ); + } + + // 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 ( +
+
+ {children} +
+
+ ); + } + + // Server determined no access, show appropriate message + if (!currentUser) { + return ( +
+
+ {/* Show partial content before protection */} + {children} +
+ + {/* Enhanced login prompt for server-side blocked content */} +
+
+
+
+ +
+ +
+

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

+

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

+
+ + + + +
+
+
+
+ ); + } + } + + // 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, 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; + } +}