feat: implement premium access checks and enhance premium content handling in blog posts
This commit is contained in:
parent
66d7dd3259
commit
481f3268db
@ -1,11 +1,13 @@
|
|||||||
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 { 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 { 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,
|
||||||
@ -83,7 +85,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 +94,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
|
||||||
@ -141,8 +151,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">
|
||||||
|
@ -9,10 +9,24 @@ interface PremiumContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This component will now rely on server-side filtering
|
* Client-side Premium Content component
|
||||||
* The <PremiumContent> tags will be removed server-side for non-premium users
|
* Note: This component now serves as a fallback for client-side rendering.
|
||||||
* This component only serves as a marker for premium sections
|
* The main security filtering happens server-side in PremiumGuard component.
|
||||||
*/
|
*/
|
||||||
export function PremiumContent({ children }: PremiumContentProps) {
|
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>;
|
return <div className="premium-content-section">{children}</div>;
|
||||||
}
|
}
|
||||||
|
@ -29,6 +29,14 @@ export function PremiumGuard({
|
|||||||
canAccess,
|
canAccess,
|
||||||
className,
|
className,
|
||||||
}: PremiumGuardProps) {
|
}: 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
|
// For non-premium articles, show content immediately with no extra processing
|
||||||
if (!isPremium) {
|
if (!isPremium) {
|
||||||
return (
|
return (
|
||||||
@ -40,9 +48,11 @@ export function PremiumGuard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = useTranslations('BlogPage');
|
// Determine if user has premium access
|
||||||
const pathname = useLocalePathname();
|
const hasPremiumAccess =
|
||||||
|
paymentData?.currentPlan &&
|
||||||
|
(!paymentData.currentPlan.isFree || paymentData.currentPlan.isLifetime);
|
||||||
|
|
||||||
// If server-side check has already determined access, use that
|
// If server-side check has already determined access, use that
|
||||||
if (canAccess !== undefined) {
|
if (canAccess !== undefined) {
|
||||||
// Server has determined the user has access
|
// Server has determined the user has access
|
||||||
@ -55,9 +65,8 @@ export function PremiumGuard({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server determined no access, show appropriate message
|
// Server determined no access, show appropriate message
|
||||||
const currentUser = useCurrentUser();
|
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={className}>
|
||||||
@ -65,7 +74,7 @@ export function PremiumGuard({
|
|||||||
{/* Show partial content before protection */}
|
{/* Show partial content before protection */}
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Enhanced login prompt for server-side blocked content */}
|
{/* Enhanced login prompt for server-side blocked content */}
|
||||||
<div className="mt-16">
|
<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="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 user is not logged in
|
||||||
if (!currentUser) {
|
if (!currentUser) {
|
||||||
return (
|
return (
|
||||||
|
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