From 481f3268db6bb952f0bfc19617ebaa7022150521 Mon Sep 17 00:00:00 2001 From: javayhu Date: Sun, 31 Aug 2025 09:55:24 +0800 Subject: [PATCH] feat: implement premium access checks and enhance premium content handling in blog posts --- .../(marketing)/blog/[...slug]/page.tsx | 24 ++++- src/components/blog/premium-content.tsx | 20 ++++- src/components/blog/premium-guard.tsx | 32 ++++--- src/lib/premium-access.ts | 89 +++++++++++++++++++ 4 files changed, 141 insertions(+), 24 deletions(-) create mode 100644 src/lib/premium-access.ts diff --git a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx index bec8bde..0a544e2 100644 --- a/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/[...slug]/page.tsx @@ -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 */} -
- +
+ + +
diff --git a/src/components/blog/premium-content.tsx b/src/components/blog/premium-content.tsx index e3b9ca8..e56fb1e 100644 --- a/src/components/blog/premium-content.tsx +++ b/src/components/blog/premium-content.tsx @@ -9,10 +9,24 @@ interface PremiumContentProps { } /** - * This component will now rely on server-side filtering - * The 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
{children}
; } diff --git a/src/components/blog/premium-guard.tsx b/src/components/blog/premium-guard.tsx index f9f6109..9462dcc 100644 --- a/src/components/blog/premium-guard.tsx +++ b/src/components/blog/premium-guard.tsx @@ -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({
); } - + // Server determined no access, show appropriate message - const currentUser = useCurrentUser(); if (!currentUser) { return (
@@ -65,7 +74,7 @@ export function PremiumGuard({ {/* Show partial content before protection */} {children}
- + {/* Enhanced login prompt for server-side blocked content */}
@@ -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 ( 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; + } +}