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"
|
||||
},
|
||||
"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",
|
||||
|
@ -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": "目录",
|
||||
|
@ -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(),
|
||||
}),
|
||||
|
@ -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">
|
||||
|
@ -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">
|
||||
|
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 { 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
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