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