feat: implement premium access checks and enhance premium content handling in blog posts

This commit is contained in:
javayhu 2025-08-31 09:55:24 +08:00
parent 66d7dd3259
commit 481f3268db
4 changed files with 141 additions and 24 deletions

View File

@ -1,11 +1,13 @@
import AllPostsButton from '@/components/blog/all-posts-button';
import BlogGrid from '@/components/blog/blog-grid';
import { PremiumGuard } from '@/components/blog/premium-guard';
import { getMDXComponents } from '@/components/docs/mdx-components';
import { NewsletterCard } from '@/components/newsletter/newsletter-card';
import { websiteConfig } from '@/config/website';
import { LocaleLink } from '@/i18n/navigation';
import { formatDate } from '@/lib/formatter';
import { constructMetadata } from '@/lib/metadata';
import { checkPremiumAccess } from '@/lib/premium-access';
import { getSession } from '@/lib/server';
import {
type BlogType,
authorSource,
@ -83,7 +85,8 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
notFound();
}
const { date, title, description, image, author, categories } = post.data;
const { date, title, description, image, author, categories, premium } =
post.data;
const publishDate = formatDate(new Date(date));
const blogAuthor = authorSource.getPage([author], locale);
@ -91,6 +94,13 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
.getPages(locale)
.filter((category) => categories.includes(category.slugs[0] ?? ''));
// Check premium access for premium posts
const session = await getSession();
const hasPremiumAccess =
premium && session?.user?.id
? await checkPremiumAccess(session.user.id)
: !premium; // Non-premium posts are always accessible
const MDX = post.data.body;
// getTranslations may cause error DYNAMIC_SERVER_USAGE, so we set dynamic to force-static
@ -141,8 +151,14 @@ export default async function BlogPostPage(props: BlogPostPageProps) {
{/* blog post content */}
{/* in order to make the mdx.css work, we need to add the className prose to the div */}
{/* https://github.com/tailwindlabs/tailwindcss-typography */}
<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

@ -9,10 +9,24 @@ interface PremiumContentProps {
}
/**
* This component will now rely on server-side filtering
* The <PremiumContent> tags will be removed server-side for non-premium users
* This component only serves as a marker for premium sections
* Client-side Premium Content component
* Note: This component now serves as a fallback for client-side rendering.
* The main security filtering happens server-side in PremiumGuard component.
*/
export function PremiumContent({ children }: PremiumContentProps) {
const currentUser = useCurrentUser();
const { data: paymentData } = useCurrentPlan(currentUser?.id);
// Determine if user has premium access
const hasPremiumAccess =
paymentData?.currentPlan &&
(paymentData.currentPlan.isLifetime || !paymentData.currentPlan.isFree);
// Only show content if user has premium access
// This is a client-side fallback - main security is handled server-side
if (!currentUser || !hasPremiumAccess) {
return null;
}
return <div className="premium-content-section">{children}</div>;
}

View File

@ -29,6 +29,14 @@ export function PremiumGuard({
canAccess,
className,
}: PremiumGuardProps) {
// All hooks must be called unconditionally at the top
const t = useTranslations('BlogPage');
const pathname = useLocalePathname();
const currentUser = useCurrentUser();
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
currentUser?.id
);
// For non-premium articles, show content immediately with no extra processing
if (!isPremium) {
return (
@ -40,9 +48,11 @@ export function PremiumGuard({
);
}
const t = useTranslations('BlogPage');
const pathname = useLocalePathname();
// Determine if user has premium access
const hasPremiumAccess =
paymentData?.currentPlan &&
(!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime);
// If server-side check has already determined access, use that
if (canAccess !== undefined) {
// Server has determined the user has access
@ -55,9 +65,8 @@ export function PremiumGuard({
</div>
);
}
// Server determined no access, show appropriate message
const currentUser = useCurrentUser();
if (!currentUser) {
return (
<div className={className}>
@ -65,7 +74,7 @@ export function PremiumGuard({
{/* 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">
@ -97,17 +106,6 @@ export function PremiumGuard({
}
}
// Fallback to client-side check when server-side check is not available
const currentUser = useCurrentUser();
const { data: paymentData, isLoading: isLoadingPayment } = useCurrentPlan(
currentUser?.id
);
// Determine if user has premium access
const hasPremiumAccess =
paymentData?.currentPlan &&
(!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime);
// If user is not logged in
if (!currentUser) {
return (

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;
}
}