Merge pull request #86 from MkSaaSHQ/dev/blog-premium
feat: premium content in blog posts
This commit is contained in:
commit
1f9a7c2621
56
content/blog/premium.mdx
Normal file
56
content/blog/premium.mdx
Normal 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>
|
56
content/blog/premium.zh.mdx
Normal file
56
content/blog/premium.zh.mdx
Normal 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>
|
@ -5,6 +5,7 @@
|
|||||||
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
|
"description": "MkSaaS is the best AI SaaS boilerplate. Make AI SaaS in days, simply and effortlessly"
|
||||||
},
|
},
|
||||||
"Common": {
|
"Common": {
|
||||||
|
"premium": "Premium",
|
||||||
"login": "Log in",
|
"login": "Log in",
|
||||||
"logout": "Log out",
|
"logout": "Log out",
|
||||||
"signUp": "Sign up",
|
"signUp": "Sign up",
|
||||||
@ -281,7 +282,20 @@
|
|||||||
"all": "All",
|
"all": "All",
|
||||||
"noPostsFound": "No posts found",
|
"noPostsFound": "No posts found",
|
||||||
"allPosts": "All Posts",
|
"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": {
|
"DocsPage": {
|
||||||
"toc": "Table of Contents",
|
"toc": "Table of Contents",
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
"description": "MkSaaS 是构建 AI SaaS 的最佳模板,使用 MkSaaS 可以在几天内轻松构建您的 AI SaaS,简单且毫不费力。"
|
||||||
},
|
},
|
||||||
"Common": {
|
"Common": {
|
||||||
|
"premium": "付费文章",
|
||||||
"login": "登录",
|
"login": "登录",
|
||||||
"logout": "退出",
|
"logout": "退出",
|
||||||
"signUp": "注册",
|
"signUp": "注册",
|
||||||
@ -281,7 +282,20 @@
|
|||||||
"all": "全部",
|
"all": "全部",
|
||||||
"noPostsFound": "没有找到文章",
|
"noPostsFound": "没有找到文章",
|
||||||
"allPosts": "全部文章",
|
"allPosts": "全部文章",
|
||||||
"morePosts": "更多文章"
|
"morePosts": "更多文章",
|
||||||
|
"premiumContent": {
|
||||||
|
"title": "解锁付费内容",
|
||||||
|
"description": "订阅我们的付费计划,访问所有付费文章和独家内容。",
|
||||||
|
"upgradeCta": "立即升级",
|
||||||
|
"benefit1": "所有文章",
|
||||||
|
"benefit2": "独家内容",
|
||||||
|
"benefit3": "随时取消",
|
||||||
|
"signIn": "登录",
|
||||||
|
"loginRequired": "登录以继续阅读",
|
||||||
|
"loginDescription": "这是一篇付费文章,请登录您的账户以访问完整内容。",
|
||||||
|
"checkingAccess": "检查阅读权限...",
|
||||||
|
"loadingContent": "加载完整内容..."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"DocsPage": {
|
"DocsPage": {
|
||||||
"toc": "目录",
|
"toc": "目录",
|
||||||
|
@ -85,7 +85,7 @@ export const category = defineCollections({
|
|||||||
/**
|
/**
|
||||||
* Blog posts
|
* Blog posts
|
||||||
*
|
*
|
||||||
* dtitle is required, but description is optional in frontmatter
|
* title is required, but description is optional in frontmatter
|
||||||
*/
|
*/
|
||||||
export const blog = defineCollections({
|
export const blog = defineCollections({
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
@ -94,6 +94,7 @@ export const blog = defineCollections({
|
|||||||
image: z.string(),
|
image: z.string(),
|
||||||
date: z.string().date(),
|
date: z.string().date(),
|
||||||
published: z.boolean().default(true),
|
published: z.boolean().default(true),
|
||||||
|
premium: z.boolean().optional(),
|
||||||
categories: z.array(z.string()),
|
categories: z.array(z.string()),
|
||||||
author: z.string(),
|
author: z.string(),
|
||||||
}),
|
}),
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
import AllPostsButton from '@/components/blog/all-posts-button';
|
import AllPostsButton from '@/components/blog/all-posts-button';
|
||||||
import BlogGrid from '@/components/blog/blog-grid';
|
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 { getMDXComponents } from '@/components/docs/mdx-components';
|
||||||
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
|
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
|
||||||
import { websiteConfig } from '@/config/website';
|
import { websiteConfig } from '@/config/website';
|
||||||
import { LocaleLink } from '@/i18n/navigation';
|
import { LocaleLink } from '@/i18n/navigation';
|
||||||
import { formatDate } from '@/lib/formatter';
|
import { formatDate } from '@/lib/formatter';
|
||||||
import { constructMetadata } from '@/lib/metadata';
|
import { constructMetadata } from '@/lib/metadata';
|
||||||
|
import { checkPremiumAccess } from '@/lib/premium-access';
|
||||||
|
import { getSession } from '@/lib/server';
|
||||||
import {
|
import {
|
||||||
type BlogType,
|
type BlogType,
|
||||||
authorSource,
|
authorSource,
|
||||||
@ -13,6 +17,7 @@ import {
|
|||||||
categorySource,
|
categorySource,
|
||||||
} from '@/lib/source';
|
} from '@/lib/source';
|
||||||
import { getUrlWithLocale } from '@/lib/urls/urls';
|
import { getUrlWithLocale } from '@/lib/urls/urls';
|
||||||
|
import { InlineTOC } from 'fumadocs-ui/components/inline-toc';
|
||||||
import { CalendarIcon, FileTextIcon } from 'lucide-react';
|
import { CalendarIcon, FileTextIcon } from 'lucide-react';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
import type { Locale } from 'next-intl';
|
import type { Locale } from 'next-intl';
|
||||||
@ -21,7 +26,6 @@ import Image from 'next/image';
|
|||||||
import { notFound } from 'next/navigation';
|
import { notFound } from 'next/navigation';
|
||||||
|
|
||||||
import '@/styles/mdx.css';
|
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,
|
* 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();
|
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 publishDate = formatDate(new Date(date));
|
||||||
|
|
||||||
const blogAuthor = authorSource.getPage([author], locale);
|
const blogAuthor = authorSource.getPage([author], locale);
|
||||||
@ -91,6 +96,13 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
.getPages(locale)
|
.getPages(locale)
|
||||||
.filter((category) => categories.includes(category.slugs[0] ?? ''));
|
.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;
|
const MDX = post.data.body;
|
||||||
|
|
||||||
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
|
// 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>
|
</div>
|
||||||
|
|
||||||
{/* blog post date */}
|
{/* blog post date and premium badge */}
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<CalendarIcon className="size-4 text-muted-foreground" />
|
<CalendarIcon className="size-4 text-muted-foreground" />
|
||||||
@ -129,6 +141,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
{publishDate}
|
{publishDate}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{premium && <PremiumBadge size="sm" />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* blog post title */}
|
{/* blog post title */}
|
||||||
@ -141,8 +155,14 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
|
|||||||
{/* blog post content */}
|
{/* blog post content */}
|
||||||
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
|
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
|
||||||
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
|
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
|
||||||
<div className="mt-8 max-w-none prose prose-neutral dark:prose-invert prose-img:rounded-lg">
|
<div className="mt-8">
|
||||||
<MDX components={getMDXComponents()} />
|
<PremiumGuard
|
||||||
|
isPremium={!!premium}
|
||||||
|
canAccess={hasPremiumAccess}
|
||||||
|
className="max-w-none"
|
||||||
|
>
|
||||||
|
<MDX components={getMDXComponents()} />
|
||||||
|
</PremiumGuard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-start my-16">
|
<div className="flex items-center justify-start my-16">
|
||||||
|
@ -4,6 +4,7 @@ import { formatDate } from '@/lib/formatter';
|
|||||||
import { type BlogType, authorSource, categorySource } from '@/lib/source';
|
import { type BlogType, authorSource, categorySource } from '@/lib/source';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import BlogImage from './blog-image';
|
import BlogImage from './blog-image';
|
||||||
|
import { PremiumBadge } from './premium-badge';
|
||||||
|
|
||||||
interface BlogCardProps {
|
interface BlogCardProps {
|
||||||
locale: string;
|
locale: string;
|
||||||
@ -30,6 +31,13 @@ export default function BlogCard({ locale, post }: BlogCardProps) {
|
|||||||
title={title || 'image for blog post'}
|
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 */}
|
{/* categories */}
|
||||||
{blogCategories && blogCategories.length > 0 && (
|
{blogCategories && blogCategories.length > 0 && (
|
||||||
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300 z-20">
|
<div className="absolute left-2 bottom-2 opacity-100 transition-opacity duration-300 z-20">
|
||||||
|
48
src/components/blog/premium-badge.tsx
Normal file
48
src/components/blog/premium-badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
32
src/components/blog/premium-content.tsx
Normal file
32
src/components/blog/premium-content.tsx
Normal 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>;
|
||||||
|
}
|
228
src/components/blog/premium-guard.tsx
Normal file
228
src/components/blog/premium-guard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,3 +1,4 @@
|
|||||||
|
import { PremiumContent } from '@/components/blog/premium-content';
|
||||||
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
import { ImageWrapper } from '@/components/docs/image-wrapper';
|
||||||
import { Wrapper } from '@/components/docs/wrapper';
|
import { Wrapper } from '@/components/docs/wrapper';
|
||||||
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
import { YoutubeVideo } from '@/components/docs/youtube-video';
|
||||||
@ -23,6 +24,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
|
|||||||
...LucideIcons,
|
...LucideIcons,
|
||||||
// ...((await import('lucide-react')) as unknown as MDXComponents),
|
// ...((await import('lucide-react')) as unknown as MDXComponents),
|
||||||
YoutubeVideo,
|
YoutubeVideo,
|
||||||
|
PremiumContent,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tab,
|
Tab,
|
||||||
TypeTable,
|
TypeTable,
|
||||||
|
89
src/lib/premium-access.ts
Normal file
89
src/lib/premium-access.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user